Merge branch 'main' into jackw/nx-task-orchestration

This commit is contained in:
Jack Westbrook
2024-02-14 12:43:41 +01:00
64 changed files with 1191 additions and 614 deletions

View File

@@ -499,7 +499,7 @@ steps:
name: identify-runner
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -851,7 +851,7 @@ steps:
name: clone-enterprise
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1774,7 +1774,7 @@ steps:
name: identify-runner
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2223,7 +2223,7 @@ services:
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2425,7 +2425,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/windows/grabpl.exe
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
name: windows-init
@@ -2555,7 +2555,7 @@ steps:
name: identify-runner
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3215,7 +3215,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/windows/grabpl.exe
- Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
name: windows-init
@@ -4017,7 +4017,7 @@ services:
steps:
- commands:
- mkdir -p bin
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.47/grabpl
- curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v3.0.50/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -4800,6 +4800,6 @@ kind: secret
name: gcr_credentials
---
kind: signature
hmac: f82455098bcac4c4b46f62ec7bc768660ccce0bb4c869da8bb85026e5845aa49
hmac: c5243aaa05fbb21c64d03465f079ad710c9dcddb81db19b89ebb61cc9b60bc72
...

View File

@@ -1,3 +1,13 @@
<!-- 10.3.3 START -->
# 10.3.3 (2024-02-02)
### Bug fixes
- **Elasticsearch:** Fix creating of legend so it is backward compatible with frontend produced frames. [#81786](https://github.com/grafana/grafana/issues/81786), [@ivanahuckova](https://github.com/ivanahuckova)
- **ShareModal:** Fixes url sync issue that caused issue with save drawer. [#81721](https://github.com/grafana/grafana/issues/81721), [@ivanortegaalba](https://github.com/ivanortegaalba)
<!-- 10.3.3 END -->
<!-- 10.3.1 START -->
# 10.3.1 (2024-01-22)
@@ -103,6 +113,29 @@ Users who have InfluxDB datasource configured with SQL querying language must up
Removes `NamespaceID` from responses of all GET routes underneath the path `/api/ruler/grafana/api/v1/rules` - 3 affected endpoints. All affected routes are not in the publicly documented or `stable` marked portion of the ngalert API. This only breaks clients who are directly using the unstable portion of the API. Such clients should use `NamespaceUID` rather than `NamespaceID` to identify namespaces. Issue [#79359](https://github.com/grafana/grafana/issues/79359)
<!-- 10.3.0 END -->
<!-- 10.2.4 START -->
# 10.2.4 (2024-01-29)
### Features and enhancements
- **Chore:** Upgrade Go to 1.21.5. [#79560](https://github.com/grafana/grafana/issues/79560), [@tolzhabayev](https://github.com/tolzhabayev)
### Bug fixes
- **Field:** Fix perf regression in getUniqueFieldName(). [#81417](https://github.com/grafana/grafana/issues/81417), [@leeoniya](https://github.com/leeoniya)
- **Alerting:** Fix Graphite subqueries. [#80816](https://github.com/grafana/grafana/issues/80816), [@gillesdemey](https://github.com/gillesdemey)
- **Alerting:** Fix Graphite subqueries. [#80744](https://github.com/grafana/grafana/issues/80744), [@gillesdemey](https://github.com/gillesdemey)
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80485](https://github.com/grafana/grafana/issues/80485), [@alexweav](https://github.com/alexweav)
- **Loki:** Fix bug duplicating parsed labels across multiple log lines. [#80368](https://github.com/grafana/grafana/issues/80368), [@svennergr](https://github.com/svennergr)
- **Alerting:** Fix NoData & Error alerts not resolving when rule is reset. [#80241](https://github.com/grafana/grafana/issues/80241), [@JacobsonMT](https://github.com/JacobsonMT)
- **Auth:** Fix a panic during logout when OAuth provider is not set. [#80221](https://github.com/grafana/grafana/issues/80221), [@dmihai](https://github.com/dmihai)
- **Gauges:** Fixing broken auto sizing. [#79940](https://github.com/grafana/grafana/issues/79940), [@torkelo](https://github.com/torkelo)
- **Templating:** Json interpolation of single-value default selection does not create valid json. [#79503](https://github.com/grafana/grafana/issues/79503), [@kaydelaney](https://github.com/kaydelaney)
- **Tempo:** Fix cache in TraceQL editor. [#79471](https://github.com/grafana/grafana/issues/79471), [@adrapereira](https://github.com/adrapereira)
- **Alerting:** Fix for data source filter on cloud rules. (#79327). [#79350](https://github.com/grafana/grafana/issues/79350), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
<!-- 10.2.4 END -->
<!-- 10.2.3 START -->
# 10.2.3 (2023-12-18)
@@ -756,6 +789,15 @@ Starting with 10.2, `parentRowIndex` is deprecated. It will be removed in a futu
- **Drawer:** Make content scroll by default. [#75287](https://github.com/grafana/grafana/issues/75287), [@ashharrison90](https://github.com/ashharrison90)
<!-- 10.2.0 END -->
<!-- 10.1.7 START -->
# 10.1.7 (2024-01-29)
### Bug fixes
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80678](https://github.com/grafana/grafana/issues/80678), [@alexweav](https://github.com/alexweav)
<!-- 10.1.7 END -->
<!-- 10.1.6 START -->
# 10.1.6 (2023-12-18)
@@ -1257,6 +1299,15 @@ Starting with 10.0, changing the folder UID is deprecated. It will be removed in
- **Grafana/ui:** Fix margin in RadioButtonGroup option when only icon is present. [#68899](https://github.com/grafana/grafana/issues/68899), [@aocenas](https://github.com/aocenas)
<!-- 10.1.0 END -->
<!-- 10.0.11 START -->
# 10.0.11 (2024-01-29)
### Bug fixes
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80681](https://github.com/grafana/grafana/issues/80681), [@alexweav](https://github.com/alexweav)
<!-- 10.0.11 END -->
<!-- 10.0.10 START -->
# 10.0.10 (2023-12-18)
@@ -1789,6 +1840,15 @@ The `database` field has been deprecated in the Elasticsearch datasource provisi
- **InteractiveTable:** Updated design and minor tweak to Correlactions page. [#66443](https://github.com/grafana/grafana/issues/66443), [@torkelo](https://github.com/torkelo)
<!-- 10.0.0-preview END -->
<!-- 9.5.16 START -->
# 9.5.16 (2024-01-29)
### Bug fixes
- **Annotations:** Split cleanup into separate queries and deletes to avoid deadlocks on MySQL. [#80682](https://github.com/grafana/grafana/issues/80682), [@alexweav](https://github.com/alexweav)
<!-- 9.5.16 END -->
<!-- 9.5.15 START -->
# 9.5.15 (2023-12-18)

View File

@@ -207,7 +207,7 @@ See note in the [introduction](#public-dashboard-api) for an explanation.
- **401** Unauthorized
- **403** Access denied
## Get a list of all public dashboards with pagination
## Get a list of all public dashboards with pagination
`GET /api/dashboards/public-dashboards`

View File

@@ -186,6 +186,7 @@ Sensitive information stripped: queries (metric, template,annotation) and panel
| `key` | string | **Yes** | | Optional, defined the unique key of the snapshot, required if external is true |
| `name` | string | **Yes** | | Optional, name of the snapshot |
| `orgId` | uint32 | **Yes** | | org id of the snapshot |
| `originalUrl` | string | **Yes** | | original url, url of the dashboard that was snapshotted |
| `updated` | string | **Yes** | | last time when the snapshot was updated |
| `userId` | uint32 | **Yes** | | user id of the snapshot creator |
| `url` | string | No | | url of the snapshot, if snapshot was shared internally |

View File

@@ -494,6 +494,8 @@ lineage: schemas: [{
external: bool @grafanamaturity(NeedsExpertReview)
// external url, if snapshot was shared in external grafana instance
externalUrl: string @grafanamaturity(NeedsExpertReview)
// original url, url of the dashboard that was snapshotted
originalUrl: string @grafanamaturity(NeedsExpertReview)
// Unique identifier of the snapshot
id: uint32 @grafanamaturity(NeedsExpertReview)
// Optional, defined the unique key of the snapshot, required if external is true

View File

@@ -238,12 +238,12 @@
"@grafana/faro-web-sdk": "^1.3.6",
"@grafana/flamegraph": "workspace:*",
"@grafana/google-sdk": "0.1.2",
"@grafana/lezer-logql": "0.2.2",
"@grafana/lezer-logql": "0.2.3",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/o11y-ds-frontend": "workspace:*",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^2.6.5",
"@grafana/scenes": "^3.2.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",

View File

@@ -182,6 +182,10 @@ export const Pages = {
stepCountIntervalSelect: 'data-testid interval variable step count input',
minIntervalInput: 'data-testid interval variable mininum interval input',
},
AdHocFiltersVariable: {
datasourceSelect: Components.DataSourcePicker.inputV2,
infoText: 'data-testid ad-hoc filters variable info text',
},
},
},
},

View File

@@ -1088,6 +1088,10 @@ export interface Dashboard {
* external url, if snapshot was shared in external grafana instance
*/
externalUrl: string;
/**
* original url, url of the dashboard that was snapshotted
*/
originalUrl: string;
/**
* Unique identifier of the snapshot
*/

View File

@@ -5,7 +5,7 @@ import { SelectableValue, GrafanaTheme2 } from '@grafana/data';
import { IconButton } from '../../components/IconButton/IconButton';
import { TabsBar, Tab, TabContent } from '../../components/Tabs';
import { useStyles2 } from '../../themes';
import { useStyles2, useTheme2 } from '../../themes';
import { IconName } from '../../types/icon';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
@@ -25,12 +25,14 @@ export interface TabbedContainerProps {
export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose }: TabbedContainerProps) {
const [activeTab, setActiveTab] = useState(tabs.some((tab) => tab.value === defaultTab) ? defaultTab : tabs[0].value);
const styles = useStyles2(getStyles);
const theme = useTheme2();
const onSelectTab = (item: SelectableValue<string>) => {
setActiveTab(item.value!);
};
const styles = useStyles2(getStyles);
const autoHeight = `calc(100% - (${theme.components.menuTabs.height}px + ${theme.spacing(1)}))`;
return (
<div className={styles.container}>
@@ -46,7 +48,7 @@ export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose }:
))}
<IconButton className={styles.close} onClick={onClose} name="times" tooltip={closeIconTooltip ?? 'Close'} />
</TabsBar>
<CustomScrollbar autoHeightMin="100%">
<CustomScrollbar autoHeightMin={autoHeight} autoHeightMax={autoHeight}>
<TabContent className={styles.tabContent}>{tabs.find((t) => t.value === activeTab)?.content}</TabContent>
</CustomScrollbar>
</div>
@@ -60,7 +62,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
tabContent: css({
padding: theme.spacing(2),
backgroundColor: theme.colors.background.primary,
height: `calc(100% - ${theme.components.menuTabs.height}px)`,
height: `100%`,
}),
close: css({
position: 'absolute',

View File

@@ -694,6 +694,9 @@ type Snapshot struct {
// OrgId org id of the snapshot
OrgId int `json:"orgId"`
// OriginalUrl original url, url of the dashboard that was snapshotted
OriginalUrl string `json:"originalUrl"`
// Updated last time when the snapshot was updated
Updated time.Time `json:"updated"`

View File

@@ -374,7 +374,7 @@
0
],
"description": "A Grafana dashboard.",
"grafanaMaturityCount": 103,
"grafanaMaturityCount": 105,
"lineageIsGroup": false,
"links": {
"docs": "https://grafana.com/docs/grafana/next/developers/kinds/core/dashboard/schema-reference",

View File

@@ -227,8 +227,10 @@ func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Fo
}
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
dashFolder.Fullpath = dashFolder.Title
return dashFolder, nil
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
// nolint:staticcheck
if q.ID != nil {
@@ -247,6 +249,10 @@ func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Fo
f.ID = dashFolder.ID
f.Version = dashFolder.Version
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
f.Fullpath = f.Title // set full path to the folder title (unescaped)
}
return f, err
}

View File

@@ -1611,6 +1611,106 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
})
}
func TestFolderServiceGetFolder(t *testing.T) {
db := sqlstore.InitTestDB(t)
signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{
orgID: {
dashboards.ActionFoldersCreate: {},
dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll},
dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll},
},
}}
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
CanSaveValue: true,
CanViewValue: true,
})
getSvc := func(features featuremgmt.FeatureToggles) Service {
quotaService := quotatest.New(false, nil)
folderStore := ProvideDashboardFolderStore(db)
cfg := setting.NewCfg()
featuresFlagOff := featuremgmt.WithFeatures()
dashStore, err := database.ProvideDashboardStore(db, db.Cfg, featuresFlagOff, tagimpl.ProvideService(db), quotaService)
require.NoError(t, err)
nestedFolderStore := ProvideStore(db, db.Cfg)
b := bus.ProvideBus(tracing.InitializeTracerForTest())
ac := acimpl.ProvideAccessControl(cfg)
return Service{
cfg: cfg,
log: log.New("test-folder-service"),
dashboardStore: dashStore,
dashboardFolderStore: folderStore,
store: nestedFolderStore,
features: features,
bus: b,
db: db,
accessControl: ac,
registry: make(map[string]folder.RegistryService),
metrics: newFoldersMetrics(nil),
}
}
folderSvcOn := getSvc(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
folderSvcOff := getSvc(featuremgmt.WithFeatures())
createCmd := folder.CreateFolderCommand{
OrgID: orgID,
ParentUID: "",
SignedInUser: &signedInAdminUser,
}
depth := 3
folders := CreateSubtreeInStore(t, folderSvcOn.store, &folderSvcOn, depth, "get/folder-", createCmd)
f := folders[1]
testCases := []struct {
name string
svc *Service
WithFullpath bool
expectedFullpath string
}{
{
name: "when flag is off",
svc: &folderSvcOff,
expectedFullpath: f.Title,
},
{
name: "when flag is on and WithFullpath is false",
svc: &folderSvcOn,
WithFullpath: false,
expectedFullpath: "",
},
{
name: "when flag is on and WithFullpath is true",
svc: &folderSvcOn,
WithFullpath: true,
expectedFullpath: "get\\/folder-folder-0/get\\/folder-folder-1",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
q := folder.GetFolderQuery{
OrgID: orgID,
UID: &f.UID,
WithFullpath: tc.WithFullpath,
SignedInUser: &signedInAdminUser,
}
fldr, err := tc.svc.Get(context.Background(), &q)
require.NoError(t, err)
require.Equal(t, f.UID, fldr.UID)
require.Equal(t, tc.expectedFullpath, fldr.Fullpath)
})
}
}
func TestFolderServiceGetFolders(t *testing.T) {
db := sqlstore.InitTestDB(t)
quotaService := quotatest.New(false, nil)
@@ -1687,7 +1787,7 @@ func TestFolderServiceGetFolders(t *testing.T) {
})
}
func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []*folder.Folder {
func CreateSubtreeInStore(t *testing.T, store store, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []*folder.Folder {
t.Helper()
folders := make([]*folder.Folder, 0, depth)

View File

@@ -171,27 +171,55 @@ func (ss *sqlStore) Update(ctx context.Context, cmd folder.UpdateFolderCommand)
return foldr.WithURL(), err
}
// If WithFullpath is true it computes also the full path of a folder.
// The full path is a string that contains the titles of all parent folders separated by a slash.
// For example, if the folder structure is:
//
// A
// └── B
// └── C
//
// The full path of C is "A/B/C".
// The full path of B is "A/B".
// The full path of A is "A".
// If a folder contains a slash in its title, it is escaped with a backslash.
// For example, if the folder structure is:
//
// A
// └── B/C
//
// The full path of C is "A/B\/C".
func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.Folder, error) {
foldr := &folder.Folder{}
err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
exists := false
var err error
s := strings.Builder{}
s.WriteString("SELECT *")
if q.WithFullpath {
s.WriteString(fmt.Sprintf(`, %s AS fullpath`, getFullpathSQL(ss.db.GetDialect())))
}
s.WriteString(" FROM folder f0")
if q.WithFullpath {
s.WriteString(getFullpathJoinsSQL())
}
switch {
case q.UID != nil:
exists, err = sess.SQL("SELECT * FROM folder WHERE uid = ? AND org_id = ?", q.UID, q.OrgID).Get(foldr)
s.WriteString(" WHERE f0.uid = ? AND f0.org_id = ?")
exists, err = sess.SQL(s.String(), q.UID, q.OrgID).Get(foldr)
// nolint:staticcheck
case q.ID != nil:
s.WriteString(" WHERE f0.id = ?")
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
exists, err = sess.SQL("SELECT * FROM folder WHERE id = ?", q.ID).Get(foldr)
exists, err = sess.SQL(s.String(), q.ID).Get(foldr)
case q.Title != nil:
s := strings.Builder{}
s.WriteString("SELECT * FROM folder WHERE title = ? AND org_id = ?")
s.WriteString(" WHERE f0.title = ? AND f0.org_id = ?")
args := []any{*q.Title, q.OrgID}
if q.ParentUID != nil {
s.WriteString(" AND parent_uid = ?")
s.WriteString(" AND f0.parent_uid = ?")
args = append(args, *q.ParentUID)
} else {
s.WriteString(" AND parent_uid IS NULL")
s.WriteString(" AND f0.parent_uid IS NULL")
}
exists, err = sess.SQL(s.String(), args...).Get(foldr)
default:
@@ -207,6 +235,7 @@ func (ss *sqlStore) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.F
return nil
})
foldr.Fullpath = strings.TrimLeft(foldr.Fullpath, "/")
return foldr.WithURL(), err
}
@@ -274,15 +303,16 @@ func (ss *sqlStore) GetChildren(ctx context.Context, q folder.GetChildrenQuery)
args = append(args, q.UID, q.OrgID)
}
if q.FolderUIDs != nil {
sql.WriteString(" AND uid IN (?")
for range q.FolderUIDs[1:] {
sql.WriteString(", ?")
}
sql.WriteString(")")
for _, uid := range q.FolderUIDs {
if len(q.FolderUIDs) > 0 {
sql.WriteString(" AND uid IN (")
for i, uid := range q.FolderUIDs {
if i > 0 {
sql.WriteString(", ")
}
sql.WriteString("?")
args = append(args, uid)
}
sql.WriteString(")")
}
sql.WriteString(" ORDER BY title ASC")

View File

@@ -3,6 +3,7 @@ package folderimpl
import (
"context"
"fmt"
"path"
"slices"
"sort"
"testing"
@@ -391,11 +392,7 @@ func TestIntegrationGet(t *testing.T) {
UID: util.GenerateShortUID(),
ParentUID: f.UID,
})
t.Cleanup(func() {
err := folderStore.Delete(context.Background(), []string{f.UID}, orgID)
require.NoError(t, err)
})
require.NoError(t, err)
t.Run("should gently fail in case of bad request", func(t *testing.T) {
_, err = folderStore.Get(context.Background(), folder.GetFolderQuery{})
@@ -466,6 +463,24 @@ func TestIntegrationGet(t *testing.T) {
assert.NotEmpty(t, ff.Updated)
assert.NotEmpty(t, ff.URL)
})
t.Run("get folder with fullpath should set fullpath as expected", func(t *testing.T) {
ff, err := folderStore.Get(context.Background(), folder.GetFolderQuery{
UID: &subfolderWithSameName.UID,
OrgID: orgID,
WithFullpath: true,
})
require.NoError(t, err)
assert.Equal(t, subfolderWithSameName.UID, ff.UID)
assert.Equal(t, subfolderWithSameName.OrgID, ff.OrgID)
assert.Equal(t, subfolderWithSameName.Title, ff.Title)
assert.Equal(t, subfolderWithSameName.Description, ff.Description)
assert.Equal(t, path.Join(f.Title, subfolderWithSameName.Title), ff.Fullpath)
assert.Equal(t, f.UID, ff.ParentUID)
assert.NotEmpty(t, ff.Created)
assert.NotEmpty(t, ff.Updated)
assert.NotEmpty(t, ff.URL)
})
}
func TestIntegrationGetParents(t *testing.T) {

View File

@@ -147,10 +147,11 @@ type DeleteFolderCommand struct {
type GetFolderQuery struct {
UID *string
// Deprecated: use FolderUID instead
ID *int64
Title *string
ParentUID *string
OrgID int64
ID *int64
Title *string
ParentUID *string
OrgID int64
WithFullpath bool
SignedInUser identity.Requester `json:"-"`
}

View File

@@ -18,6 +18,7 @@ type Service interface {
// specificity (UID, ID, Title).
// When fetching a folder by Title, callers can optionally define a ParentUID.
// If ParentUID is not set then the folder will be fetched from the root level.
// If WithFullpath is true it computes also the full path of a folder.
Get(ctx context.Context, q *GetFolderQuery) (*Folder, error)
// Update is used to update a folder's UID, Title and Description. To change

View File

@@ -24,5 +24,5 @@ type RuleStore interface {
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
// IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace
IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error)
IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error)
}

View File

@@ -368,11 +368,6 @@ type AlertRuleKeyWithVersion struct {
AlertRuleKey `xorm:"extends"`
}
type AlertRuleKeyWithVersionAndPauseStatus struct {
IsPaused bool
AlertRuleKeyWithVersion `xorm:"extends"`
}
type AlertRuleKeyWithId struct {
AlertRuleKey
ID int64

View File

@@ -72,8 +72,8 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI
}
// IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace
func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersionAndPauseStatus, error) {
var keys []ngmodels.AlertRuleKeyWithVersionAndPauseStatus
func (st DBstore) IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) {
var keys []ngmodels.AlertRuleKeyWithVersion
err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
now := TimeNow()
_, err := sess.Exec("UPDATE alert_rule SET version = version + 1, updated = ? WHERE namespace_uid = ? AND org_id = ?", now, namespaceUID, orgID)

View File

@@ -315,7 +315,7 @@ func (f *RuleStore) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceU
return nil
}
func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersionAndPauseStatus, error) {
func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, orgID int64, namespaceUID string) ([]models.AlertRuleKeyWithVersion, error) {
f.mtx.Lock()
defer f.mtx.Unlock()
@@ -324,18 +324,15 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org
Params: []any{orgID, namespaceUID},
})
var result []models.AlertRuleKeyWithVersionAndPauseStatus
var result []models.AlertRuleKeyWithVersion
for _, rule := range f.Rules[orgID] {
if rule.NamespaceUID == namespaceUID && rule.OrgID == orgID {
rule.Version++
rule.Updated = time.Now()
result = append(result, models.AlertRuleKeyWithVersionAndPauseStatus{
IsPaused: rule.IsPaused,
AlertRuleKeyWithVersion: models.AlertRuleKeyWithVersion{
Version: rule.Version,
AlertRuleKey: rule.GetKey(),
},
result = append(result, models.AlertRuleKeyWithVersion{
Version: rule.Version,
AlertRuleKey: rule.GetKey(),
})
}
}

View File

@@ -7,11 +7,11 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
jsoniter "github.com/json-iterator/go"
"github.com/grafana/grafana/pkg/tsdb/influxdb/influxql/util"
"github.com/grafana/grafana/pkg/tsdb/influxdb/models"
"github.com/grafana/grafana/pkg/util/converter/jsonitere"
)
func rspErr(e error) *backend.DataResponse {
@@ -19,7 +19,7 @@ func rspErr(e error) *backend.DataResponse {
}
func ReadInfluxQLStyleResult(jIter *jsoniter.Iterator, query *models.Query) *backend.DataResponse {
iter := jsonitere.NewIterator(jIter)
iter := sdkjsoniter.NewIterator(jIter)
var rsp *backend.DataResponse
l1Fields:
@@ -51,7 +51,7 @@ l1Fields:
return rsp
}
func readResults(iter *jsonitere.Iterator, query *models.Query) *backend.DataResponse {
func readResults(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataResponse {
rsp := &backend.DataResponse{Frames: make(data.Frames, 0)}
l1Fields:
for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() {
@@ -79,7 +79,7 @@ l1Fields:
return rsp
}
func readSeries(iter *jsonitere.Iterator, query *models.Query) *backend.DataResponse {
func readSeries(iter *sdkjsoniter.Iterator, query *models.Query) *backend.DataResponse {
var (
measurement string
tags map[string]string
@@ -179,7 +179,7 @@ func readSeries(iter *jsonitere.Iterator, query *models.Query) *backend.DataResp
return rsp
}
func readTags(iter *jsonitere.Iterator) (map[string]string, error) {
func readTags(iter *sdkjsoniter.Iterator) (map[string]string, error) {
tags := make(map[string]string)
for l1Field, err := iter.ReadObject(); l1Field != ""; l1Field, err = iter.ReadObject() {
if err != nil {
@@ -194,7 +194,7 @@ func readTags(iter *jsonitere.Iterator) (map[string]string, error) {
return tags, nil
}
func readColumns(iter *jsonitere.Iterator) (columns []string, err error) {
func readColumns(iter *sdkjsoniter.Iterator) (columns []string, err error) {
for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() {
if err != nil {
return nil, err
@@ -209,7 +209,7 @@ func readColumns(iter *jsonitere.Iterator) (columns []string, err error) {
return columns, nil
}
func readValues(iter *jsonitere.Iterator, hasTimeColumn bool) (valueFields data.Fields, err error) {
func readValues(iter *sdkjsoniter.Iterator, hasTimeColumn bool) (valueFields data.Fields, err error) {
if hasTimeColumn {
valueFields = append(valueFields, data.NewField("Time", nil, make([]time.Time, 0)))
}

View File

@@ -1,66 +0,0 @@
// Package jsonitere wraps json-iterator/go's Iterator methods with error returns
// so linting can catch unchecked errors.
// The underlying iterator's Error property is returned and not reset.
// See json-iterator/go for method documentation and additional methods that
// can be added to this library.
package jsonitere
import (
j "github.com/json-iterator/go"
)
type Iterator struct {
// named property instead of embedded so there is no
// confusion about which method or property is called
i *j.Iterator
}
func NewIterator(i *j.Iterator) *Iterator {
return &Iterator{i}
}
func (iter *Iterator) Read() (any, error) {
return iter.i.Read(), iter.i.Error
}
func (iter *Iterator) ReadAny() (j.Any, error) {
return iter.i.ReadAny(), iter.i.Error
}
func (iter *Iterator) ReadArray() (bool, error) {
return iter.i.ReadArray(), iter.i.Error
}
func (iter *Iterator) ReadObject() (string, error) {
return iter.i.ReadObject(), iter.i.Error
}
func (iter *Iterator) ReadString() (string, error) {
return iter.i.ReadString(), iter.i.Error
}
func (iter *Iterator) WhatIsNext() (j.ValueType, error) {
return iter.i.WhatIsNext(), iter.i.Error
}
func (iter *Iterator) Skip() error {
iter.i.Skip()
return iter.i.Error
}
func (iter *Iterator) SkipAndReturnBytes() []byte {
return iter.i.SkipAndReturnBytes()
}
func (iter *Iterator) ReadVal(obj any) error {
iter.i.ReadVal(obj)
return iter.i.Error
}
func (iter *Iterator) ReadFloat64() (float64, error) {
return iter.i.ReadFloat64(), iter.i.Error
}
func (iter *Iterator) ReadInt8() (int8, error) {
return iter.i.ReadInt8(), iter.i.Error
}

View File

@@ -8,15 +8,15 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
sdkjsoniter "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
jsoniter "github.com/json-iterator/go"
"golang.org/x/exp/slices"
"github.com/grafana/grafana/pkg/util/converter/jsonitere"
"golang.org/x/exp/slices"
)
// helpful while debugging all the options that may appear
func logf(format string, a ...any) {
//fmt.Printf(format, a...)
// fmt.Printf(format, a...)
}
type Options struct {
@@ -29,7 +29,7 @@ func rspErr(e error) backend.DataResponse {
// ReadPrometheusStyleResult will read results from a prometheus or loki server and return data frames
func ReadPrometheusStyleResult(jIter *jsoniter.Iterator, opt Options) backend.DataResponse {
iter := jsonitere.NewIterator(jIter)
iter := sdkjsoniter.NewIterator(jIter)
var rsp backend.DataResponse
status := "unknown"
errorType := ""
@@ -102,14 +102,14 @@ l1Fields:
return rsp
}
func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) {
func readWarnings(iter *sdkjsoniter.Iterator) ([]data.Notice, error) {
warnings := []data.Notice{}
next, err := iter.WhatIsNext()
if err != nil {
return nil, err
}
if next != jsoniter.ArrayValue {
if next != sdkjsoniter.ArrayValue {
return warnings, nil
}
@@ -121,7 +121,7 @@ func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) {
if err != nil {
return nil, err
}
if next == jsoniter.StringValue {
if next == sdkjsoniter.StringValue {
s, err := iter.ReadString()
if err != nil {
return nil, err
@@ -137,18 +137,18 @@ func readWarnings(iter *jsonitere.Iterator) ([]data.Notice, error) {
return warnings, nil
}
func readPrometheusData(iter *jsonitere.Iterator, opt Options) backend.DataResponse {
func readPrometheusData(iter *sdkjsoniter.Iterator, opt Options) backend.DataResponse {
var rsp backend.DataResponse
t, err := iter.WhatIsNext()
if err != nil {
return rspErr(err)
}
if t == jsoniter.ArrayValue {
if t == sdkjsoniter.ArrayValue {
return readArrayData(iter)
}
if t != jsoniter.ObjectValue {
if t != sdkjsoniter.ObjectValue {
return backend.DataResponse{
Error: fmt.Errorf("expected object type"),
}
@@ -190,7 +190,7 @@ l1Fields:
// if we have saved resultBytes we will parse them here
// we saved them because when we had them we don't know the resultType
if len(resultBytes) > 0 {
ji := jsonitere.NewIterator(jsoniter.ParseBytes(jsoniter.ConfigDefault, resultBytes))
ji := sdkjsoniter.NewIterator(jsoniter.ParseBytes(sdkjsoniter.ConfigDefault, resultBytes))
rsp = readResult(resultType, rsp, ji, opt, encodingFlags)
}
case "result":
@@ -200,7 +200,7 @@ l1Fields:
if resultTypeFound {
rsp = readResult(resultType, rsp, iter, opt, encodingFlags)
} else {
resultBytes = iter.SkipAndReturnBytes()
resultBytes, _ = iter.SkipAndReturnBytes()
}
case "stats":
@@ -241,7 +241,7 @@ l1Fields:
}
// will read the result object based on the resultType and return a DataResponse
func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Iterator, opt Options, encodingFlags []string) backend.DataResponse {
func readResult(resultType string, rsp backend.DataResponse, iter *sdkjsoniter.Iterator, opt Options, encodingFlags []string) backend.DataResponse {
switch resultType {
case "matrix", "vector":
rsp = readMatrixOrVectorMulti(iter, resultType, opt)
@@ -279,7 +279,7 @@ func readResult(resultType string, rsp backend.DataResponse, iter *jsonitere.Ite
}
// will return strings or exemplars
func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
func readArrayData(iter *sdkjsoniter.Iterator) backend.DataResponse {
lookup := make(map[string]*data.Field)
var labelFrame *data.Frame
@@ -298,7 +298,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
}
switch next {
case jsoniter.StringValue:
case sdkjsoniter.StringValue:
s, err := iter.ReadString()
if err != nil {
return rspErr(err)
@@ -306,7 +306,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
stringField.Append(s)
// Either label or exemplars
case jsoniter.ObjectValue:
case sdkjsoniter.ObjectValue:
exemplar, labelPairs, err := readLabelsOrExemplars(iter)
if err != nil {
rspErr(err)
@@ -365,7 +365,7 @@ func readArrayData(iter *jsonitere.Iterator) backend.DataResponse {
}
// For consistent ordering read values to an array not a map
func readLabelsAsPairs(iter *jsonitere.Iterator) ([][2]string, error) {
func readLabelsAsPairs(iter *sdkjsoniter.Iterator) ([][2]string, error) {
pairs := make([][2]string, 0, 10)
for k, err := iter.ReadObject(); k != ""; k, err = iter.ReadObject() {
if err != nil {
@@ -380,7 +380,7 @@ func readLabelsAsPairs(iter *jsonitere.Iterator) ([][2]string, error) {
return pairs, nil
}
func readLabelsOrExemplars(iter *jsonitere.Iterator) (*data.Frame, [][2]string, error) {
func readLabelsOrExemplars(iter *sdkjsoniter.Iterator) (*data.Frame, [][2]string, error) {
pairs := make([][2]string, 0, 10)
labels := data.Labels{}
var frame *data.Frame
@@ -496,7 +496,7 @@ l1Fields:
return frame, pairs, nil
}
func readString(iter *jsonitere.Iterator) backend.DataResponse {
func readString(iter *sdkjsoniter.Iterator) backend.DataResponse {
timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0)
timeField.Name = data.TimeSeriesTimeFieldName
valueField := data.NewFieldFromFieldType(data.FieldTypeString, 0)
@@ -541,7 +541,7 @@ func readString(iter *jsonitere.Iterator) backend.DataResponse {
}
}
func readScalar(iter *jsonitere.Iterator, dataPlane bool) backend.DataResponse {
func readScalar(iter *sdkjsoniter.Iterator, dataPlane bool) backend.DataResponse {
rsp := backend.DataResponse{}
timeField := data.NewFieldFromFieldType(data.FieldTypeTime, 0)
@@ -573,7 +573,7 @@ func readScalar(iter *jsonitere.Iterator, dataPlane bool) backend.DataResponse {
}
}
func readMatrixOrVectorMulti(iter *jsonitere.Iterator, resultType string, opt Options) backend.DataResponse {
func readMatrixOrVectorMulti(iter *sdkjsoniter.Iterator, resultType string, opt Options) backend.DataResponse {
rsp := backend.DataResponse{}
for more, err := iter.ReadArray(); more; more, err = iter.ReadArray() {
@@ -679,7 +679,7 @@ func readMatrixOrVectorMulti(iter *jsonitere.Iterator, resultType string, opt Op
return rsp
}
func readTimeValuePair(iter *jsonitere.Iterator) (time.Time, float64, error) {
func readTimeValuePair(iter *sdkjsoniter.Iterator) (time.Time, float64, error) {
if _, err := iter.ReadArray(); err != nil {
return time.Time{}, 0, err
}
@@ -708,7 +708,7 @@ func readTimeValuePair(iter *jsonitere.Iterator) (time.Time, float64, error) {
}
type histogramInfo struct {
//XMax (time) YMin Ymax Count YLayout
// XMax (time) YMin Ymax Count YLayout
time *data.Field
yMin *data.Field // will have labels?
yMax *data.Field
@@ -734,7 +734,7 @@ func newHistogramInfo() *histogramInfo {
// This will read a single sparse histogram
// [ time, { count, sum, buckets: [...] }]
func readHistogram(iter *jsonitere.Iterator, hist *histogramInfo) error {
func readHistogram(iter *sdkjsoniter.Iterator, hist *histogramInfo) error {
// first element
if _, err := iter.ReadArray(); err != nil {
return err
@@ -834,7 +834,7 @@ func readHistogram(iter *jsonitere.Iterator, hist *histogramInfo) error {
return nil
}
func appendValueFromString(iter *jsonitere.Iterator, field *data.Field) error {
func appendValueFromString(iter *sdkjsoniter.Iterator, field *data.Field) error {
var err error
var s string
if s, err = iter.ReadString(); err != nil {
@@ -850,7 +850,7 @@ func appendValueFromString(iter *jsonitere.Iterator, field *data.Field) error {
return nil
}
func readStream(iter *jsonitere.Iterator) backend.DataResponse {
func readStream(iter *sdkjsoniter.Iterator) backend.DataResponse {
rsp := backend.DataResponse{}
labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0)
@@ -950,7 +950,7 @@ func readStream(iter *jsonitere.Iterator) backend.DataResponse {
return rsp
}
func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse {
func readCategorizedStream(iter *sdkjsoniter.Iterator) backend.DataResponse {
rsp := backend.DataResponse{}
labelsField := data.NewFieldFromFieldType(data.FieldTypeJSON, 0)
@@ -1084,7 +1084,7 @@ func readCategorizedStream(iter *jsonitere.Iterator) backend.DataResponse {
return rsp
}
func readCategorizedStreamField(iter *jsonitere.Iterator) (map[string]interface{}, map[string]interface{}, error) {
func readCategorizedStreamField(iter *sdkjsoniter.Iterator) (map[string]interface{}, map[string]interface{}, error) {
parsedLabels := data.Labels{}
structuredMetadata := data.Labels{}
var parsedLabelsMap map[string]interface{}

View File

@@ -30,6 +30,8 @@ export interface ContactPointSelectorProps {
refetchReceivers: () => Promise<unknown>;
}
const MAX_CONTACT_POINTS_RENDERED = 500;
export function ContactPointSelector({
alertManager,
options,
@@ -42,9 +44,10 @@ export function ContactPointSelector({
const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`);
const selectedContactPointWithMetadata = options.find((option) => option.value.name === contactPointInForm)?.value;
const selectedContactPointSelectableValue = selectedContactPointWithMetadata
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
: undefined;
const selectedContactPointSelectableValue: SelectableValue<ContactPointWithMetadata> =
selectedContactPointWithMetadata
? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name }
: { value: undefined, label: '' };
const LOADING_SPINNER_DURATION = 1000;
@@ -80,8 +83,8 @@ export function ContactPointSelector({
render={({ field: { onChange, ref, ...field }, fieldState: { error } }) => (
<>
<div className={styles.contactPointsSelector}>
<Select
{...field}
<Select<ContactPointWithMetadata>
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
aria-label="Contact point"
defaultValue={selectedContactPointSelectableValue}
onChange={(value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { Stack, Text } from '@grafana/ui';
import { RuleHealth } from 'app/types/unified-alerting';
@@ -18,14 +18,7 @@ export const RecordingBadge = ({ health }: RecordingBadgeProps) => {
const color = hasError ? 'error' : 'success';
const text = hasError ? 'Recording error' : 'Recording';
return (
<Stack direction="row" gap={0.5}>
<AlertStateDot color={color} />
<Text variant="bodySmall" color={color}>
{text}
</Text>
</Stack>
);
return <Badge color={color} text={text} />;
};
// we're making a distinction here between the "state" of the rule and its "health".
@@ -36,7 +29,7 @@ interface StateBadgeProps {
export const StateBadge = ({ state, health }: StateBadgeProps) => {
let stateLabel: string;
let color: 'success' | 'error' | 'warning';
let color: BadgeColor;
switch (state) {
case PromAlertingRuleState.Inactive:
@@ -64,12 +57,24 @@ export const StateBadge = ({ state, health }: StateBadgeProps) => {
stateLabel = 'No data';
}
return <Badge color={color} text={stateLabel} />;
};
// the generic badge component
type BadgeColor = 'success' | 'error' | 'warning';
interface BadgeProps {
color: BadgeColor;
text: NonNullable<ReactNode>;
}
function Badge({ color, text }: BadgeProps) {
return (
<Stack direction="row" gap={0.5}>
<Stack direction="row" gap={0.5} wrap={'nowrap'} flex={'0 0 auto'}>
<AlertStateDot color={color} />
<Text variant="bodySmall" color={color}>
{stateLabel}
{text}
</Text>
</Stack>
);
};
}

View File

@@ -1,5 +1,4 @@
import { CoreApp } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import {
sceneGraph,
SceneGridItem,
@@ -12,12 +11,10 @@ import {
VizPanel,
} from '@grafana/scenes';
import { Dashboard } from '@grafana/schema';
import { ConfirmModal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { VariablesChanged } from 'app/features/variables/types';
import { ShowModalReactEvent } from '../../../types/events';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
import { historySrv } from '../settings/version-history/HistorySrv';
@@ -208,63 +205,6 @@ describe('DashboardScene', () => {
}
});
});
describe('when opening a dashboard from a snapshot', () => {
let scene: DashboardScene;
beforeEach(async () => {
scene = buildTestScene();
locationService.push('/');
// mockLocationHref('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
const location = window.location;
//@ts-ignore
delete window.location;
window.location = {
...location,
href: 'http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash',
};
jest.spyOn(appEvents, 'publish');
});
config.appUrl = 'http://snapshots.grafana.com/';
it('redirects to the original dashboard', () => {
scene.setInitialSaveModel({
// @ts-ignore
snapshot: { originalUrl: '/d/c0d2742f-b827-466d-9269-fb34d6af24ff' },
});
// Call the function
scene.onOpenSnapshotOriginalDashboard();
// Assertions
expect(appEvents.publish).toHaveBeenCalledTimes(0);
expect(locationService.getLocation().pathname).toEqual('/d/c0d2742f-b827-466d-9269-fb34d6af24ff');
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
});
it('opens a confirmation modal', () => {
scene.setInitialSaveModel({
// @ts-ignore
snapshot: { originalUrl: 'http://www.anotherdomain.com/' },
});
// Call the function
scene.onOpenSnapshotOriginalDashboard();
// Assertions
expect(appEvents.publish).toHaveBeenCalledTimes(1);
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: ConfirmModal,
})
)
);
expect(locationService.getLocation().pathname).toEqual('/');
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
});
});
});
function buildTestScene(overrides?: Partial<DashboardSceneState>) {

View File

@@ -1,10 +1,8 @@
import { css } from '@emotion/css';
import * as H from 'history';
import React from 'react';
import { Unsubscribable } from 'rxjs';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, textUtil } from '@grafana/data';
import { locationService, config } from '@grafana/runtime';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
dataLayers,
getUrlSyncManager,
@@ -25,7 +23,6 @@ import {
VizPanel,
} from '@grafana/scenes';
import { Dashboard, DashboardLink } from '@grafana/schema';
import { ConfirmModal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { getNavModel } from 'app/core/selectors/navModel';
@@ -35,7 +32,7 @@ import { DashboardModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
import { ShowModalReactEvent, ShowConfirmModalEvent } from 'app/types/events';
import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
@@ -503,47 +500,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}
}
public onOpenSnapshotOriginalDashboard = () => {
// @ts-ignore
const relativeURL = this.getInitialSaveModel()?.snapshot?.originalUrl ?? '';
const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL);
try {
const sanitizedAppUrl = new URL(sanitizedRelativeURL, config.appUrl);
const appUrl = new URL(config.appUrl);
if (sanitizedAppUrl.host !== appUrl.host) {
appEvents.publish(
new ShowModalReactEvent({
component: ConfirmModal,
props: {
title: 'Proceed to external site?',
modalClass: css({
width: 'max-content',
maxWidth: '80vw',
}),
body: (
<>
<p>
{`This link connects to an external website at`} <code>{relativeURL}</code>
</p>
<p>{"Are you sure you'd like to proceed?"}</p>
</>
),
confirmVariant: 'primary',
confirmText: 'Proceed',
onConfirm: () => {
window.location.href = sanitizedAppUrl.href;
},
},
})
);
} else {
locationService.push(sanitizedRelativeURL);
}
} catch (err) {
console.error('Failed to open original dashboard', err);
}
};
public onOpenSettings = () => {
locationService.partial({ editview: 'settings' });
};

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { config, locationService } from '@grafana/runtime';
import { ConfirmModal } from '@grafana/ui';
import appEvents from '../../../core/app_events';
import { ShowModalReactEvent } from '../../../types/events';
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
describe('GoToSnapshotOriginButton component', () => {
beforeEach(async () => {
locationService.push('/');
const location = window.location;
//@ts-ignore
delete window.location;
window.location = {
...location,
href: 'http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash',
};
jest.spyOn(appEvents, 'publish');
});
config.appUrl = 'http://snapshots.grafana.com/';
it('renders button and triggers onClick redirects to the original dashboard', () => {
render(<GoToSnapshotOriginButton originalURL={'/d/c0d2742f-b827-466d-9269-fb34d6af24ff'} />);
// Check if the button renders with the correct testid
expect(screen.getByTestId('button-snapshot')).toBeInTheDocument();
// Simulate a button click
fireEvent.click(screen.getByTestId('button-snapshot'));
expect(appEvents.publish).toHaveBeenCalledTimes(0);
expect(locationService.getLocation().pathname).toEqual('/d/c0d2742f-b827-466d-9269-fb34d6af24ff');
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
});
it('renders button and triggers onClick opens a confirmation modal', () => {
render(<GoToSnapshotOriginButton originalURL={'http://www.anotherdomain.com/'} />);
// Check if the button renders with the correct testid
expect(screen.getByTestId('button-snapshot')).toBeInTheDocument();
// Simulate a button click
fireEvent.click(screen.getByTestId('button-snapshot'));
expect(appEvents.publish).toHaveBeenCalledTimes(1);
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: ConfirmModal,
})
)
);
expect(locationService.getLocation().pathname).toEqual('/');
expect(window.location.href).toBe('http://snapshots.grafana.com/snapshots/dashboard/abcdefghi/my-dash');
});
});

View File

@@ -0,0 +1,62 @@
import { css } from '@emotion/css';
import React from 'react';
import { textUtil } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { ConfirmModal, ToolbarButton } from '@grafana/ui';
import appEvents from '../../../core/app_events';
import { t } from '../../../core/internationalization';
import { ShowModalReactEvent } from '../../../types/events';
export function GoToSnapshotOriginButton(props: { originalURL: string }) {
return (
<ToolbarButton
key="button-snapshot"
data-testid="button-snapshot"
tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')}
icon="link"
onClick={() => onOpenSnapshotOriginalDashboard(props.originalURL)}
/>
);
}
const onOpenSnapshotOriginalDashboard = (originalUrl: string) => {
const relativeURL = originalUrl ?? '';
const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL);
try {
const sanitizedAppUrl = new URL(sanitizedRelativeURL, config.appUrl);
const appUrl = new URL(config.appUrl);
if (sanitizedAppUrl.host !== appUrl.host) {
appEvents.publish(
new ShowModalReactEvent({
component: ConfirmModal,
props: {
title: 'Proceed to external site?',
modalClass: css({
width: 'max-content',
maxWidth: '80vw',
}),
body: (
<>
<p>
{`This link connects to an external website at`} <code>{relativeURL}</code>
</p>
<p>{"Are you sure you'd like to proceed?"}</p>
</>
),
confirmVariant: 'primary',
confirmText: 'Proceed',
onConfirm: () => {
window.location.href = sanitizedAppUrl.href;
},
},
})
);
} else {
locationService.push(sanitizedRelativeURL);
}
} catch (err) {
console.error('Failed to open original dashboard', err);
}
};

View File

@@ -15,6 +15,7 @@ import { DashboardInteractions } from '../utils/interactions';
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { DashboardScene } from './DashboardScene';
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
interface Props {
dashboard: DashboardScene;
@@ -89,14 +90,7 @@ export function ToolbarActions({ dashboard }: Props) {
group: 'icon-actions',
condition: meta.isSnapshot && !isEditing,
render: () => (
<ToolbarButton
key="button-snapshot"
tooltip={t('dashboard.toolbar.open-original', 'Open original dashboard')}
icon="link"
onClick={() => {
dashboard.onOpenSnapshotOriginalDashboard();
}}
/>
<GoToSnapshotOriginButton originalURL={dashboard.getInitialSaveModel()?.snapshot?.originalUrl ?? ''} />
),
});

View File

@@ -5,7 +5,7 @@ import { PanelContext } from '@grafana/ui';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../utils/utils';
import { getAdHocFilterSetFor, setDashboardPanelContext } from './setDashboardPanelContext';
import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext';
const postFn = jest.fn();
const putFn = jest.fn();
@@ -132,26 +132,26 @@ describe('setDashboardPanelContext', () => {
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' });
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
expect(set.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
});
it('Should update and add filter to existing set', () => {
const { scene, context } = buildTestScene({ existingFilterSet: true });
const { scene, context } = buildTestScene({ existingFilterVariable: true });
const set = getAdHocFilterSetFor(scene, { uid: 'my-ds-uid' });
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
set.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] });
variable.setState({ filters: [{ key: 'existing', value: 'world', operator: '=' }] });
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
expect(set.state.filters.length).toBe(2);
expect(variable.state.filters.length).toBe(2);
// Can update existing filter value without adding a new filter
context.onAddAdHocFilter!({ key: 'hello', value: 'world2', operator: '=' });
// Verify existing filter value updated
expect(set.state.filters[1].value).toBe('world2');
expect(variable.state.filters[1].value).toBe('world2');
});
});
});
@@ -163,7 +163,7 @@ interface SceneOptions {
canEdit?: boolean;
canDelete?: boolean;
orgCanEdit?: boolean;
existingFilterSet?: boolean;
existingFilterVariable?: boolean;
}
function buildTestScene(options: SceneOptions) {
@@ -198,7 +198,7 @@ function buildTestScene(options: SceneOptions) {
},
],
templating: {
list: options.existingFilterSet
list: options.existingFilterVariable
? [
{
type: 'adhoc',

View File

@@ -1,5 +1,5 @@
import { AnnotationChangeEvent, AnnotationEventUIModel, CoreApp, DataFrame } from '@grafana/data';
import { AdHocFilterSet, dataLayers, SceneDataLayers, VizPanel } from '@grafana/scenes';
import { AdHocFiltersVariable, dataLayers, SceneDataLayers, sceneGraph, sceneUtils, VizPanel } from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { deleteAnnotation, saveAnnotation, updateAnnotation } from 'app/features/annotations/api';
@@ -111,8 +111,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return;
}
const filterSet = getAdHocFilterSetFor(dashboard, queryRunner.state.datasource);
updateAdHocFilterSet(filterSet, newFilter);
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
updateAdHocFilterVariable(filterVar, newFilter);
};
context.onUpdateData = (frames: DataFrame[]): Promise<boolean> => {
@@ -149,33 +149,37 @@ function reRunBuiltInAnnotationsLayer(scene: DashboardScene) {
}
}
export function getAdHocFilterSetFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
const controls = scene.state.controls ?? [];
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
const variables = sceneGraph.getVariables(scene);
for (const control of controls) {
if (control instanceof AdHocFilterSet) {
if (control.state.datasource === ds || control.state.datasource?.uid === ds?.uid) {
return control;
for (const variable of variables.state.variables) {
if (sceneUtils.isAdHocVariable(variable)) {
const filtersDs = variable.state.datasource;
if (filtersDs === ds || filtersDs?.uid === ds?.uid) {
return variable;
}
}
}
const newSet = new AdHocFilterSet({ datasource: ds });
// Add it to the scene
scene.setState({
controls: [controls[0], newSet, ...controls.slice(1)],
const newVariable = new AdHocFiltersVariable({
name: 'Filters',
datasource: ds,
});
return newSet;
// Add it to the scene
variables.setState({
variables: [...variables.state.variables, newVariable],
});
return newVariable;
}
function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterItem) {
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
// Check if we need to update an existing filter
for (const filter of filterSet.state.filters) {
for (const filter of filterVar.state.filters) {
if (filter.key === newFilter.key) {
filterSet.setState({
filters: filterSet.state.filters.map((f) => {
filterVar.setState({
filters: filterVar.state.filters.map((f) => {
if (f.key === newFilter.key) {
return newFilter;
}
@@ -187,7 +191,7 @@ function updateAdHocFilterSet(filterSet: AdHocFilterSet, newFilter: AdHocFilterI
}
// Add new filter
filterSet.setState({
filters: [...filterSet.state.filters, newFilter],
filterVar.setState({
filters: [...filterVar.state.filters, newFilter],
});
}

View File

@@ -455,6 +455,16 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
],
"templating": {
"list": [
{
"baseFilters": [],
"datasource": {
"type": "prometheus",
"uid": "wc2AL7L7k",
},
"filters": [],
"name": "Filters",
"type": "adhoc",
},
{
"auto": true,
"auto_count": 30,
@@ -525,14 +535,6 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
"skipUrlSync": true,
"type": "constant",
},
{
"datasource": {
"type": "prometheus",
"uid": "wc2AL7L7k",
},
"name": "Filters",
"type": "adhoc",
},
],
},
"time": {
@@ -757,6 +759,16 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
],
"templating": {
"list": [
{
"baseFilters": [],
"datasource": {
"type": "prometheus",
"uid": "wc2AL7L7k",
},
"filters": [],
"name": "Filters",
"type": "adhoc",
},
{
"auto": true,
"auto_count": 30,
@@ -827,14 +839,6 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
"skipUrlSync": true,
"type": "constant",
},
{
"datasource": {
"type": "prometheus",
"uid": "wc2AL7L7k",
},
"name": "Filters",
"type": "adhoc",
},
],
},
"time": {

View File

@@ -13,6 +13,7 @@ import {
} from '@grafana/data';
import { setRunRequest } from '@grafana/runtime';
import {
AdHocFiltersVariable,
ConstantVariable,
CustomVariable,
DataSourceVariable,
@@ -344,4 +345,60 @@ describe('sceneVariablesSetToVariables', () => {
}
`);
});
it('should handle AdHocFiltersVariable', () => {
const variable = new AdHocFiltersVariable({
name: 'test',
label: 'test-label',
description: 'test-desc',
datasource: { uid: 'fake-std', type: 'fake-std' },
filters: [
{
key: 'filterTest',
operator: '=',
value: 'test',
},
],
baseFilters: [
{
key: 'baseFilterTest',
operator: '=',
value: 'test',
},
],
});
const set = new SceneVariableSet({
variables: [variable],
});
const result = sceneVariablesSetToVariables(set);
expect(result).toHaveLength(1);
expect(result[0]).toMatchInlineSnapshot(`
{
"baseFilters": [
{
"key": "baseFilterTest",
"operator": "=",
"value": "test",
},
],
"datasource": {
"type": "fake-std",
"uid": "fake-std",
},
"description": "test-desc",
"filters": [
{
"key": "filterTest",
"operator": "=",
"value": "test",
},
],
"label": "test-label",
"name": "test",
"type": "adhoc",
}
`);
});
});

View File

@@ -104,6 +104,16 @@ export function sceneVariablesSetToVariables(set: SceneVariables) {
},
query: variable.state.value,
});
} else if (sceneUtils.isAdHocVariable(variable)) {
variables.push({
...commonProperties,
name: variable.state.name!,
type: 'adhoc',
datasource: variable.state.datasource,
// @ts-expect-error
baseFilters: variable.state.baseFilters,
filters: variable.state.filters,
});
} else {
throw new Error('Unsupported variable type');
}

View File

@@ -11,8 +11,9 @@ import {
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config } from '@grafana/runtime';
import {
AdHocFilterSet,
AdHocFiltersVariable,
behaviors,
ConstantVariable,
CustomVariable,
DataSourceVariable,
QueryVariable,
@@ -120,11 +121,11 @@ describe('transformSaveModelToScene', () => {
expect(scene.state?.$timeRange?.state.timeZone).toEqual('America/New_York');
expect(scene.state?.$timeRange?.state.weekStart).toEqual('saturday');
expect(scene.state?.$variables?.state.variables).toHaveLength(1);
expect(scene.state?.$variables?.state.variables).toHaveLength(2);
expect(scene.state?.$variables?.getByName('constant')).toBeInstanceOf(ConstantVariable);
expect(scene.state?.$variables?.getByName('CoolFilters')).toBeInstanceOf(AdHocFiltersVariable);
expect(dashboardControls).toBeDefined();
expect(dashboardControls).toBeInstanceOf(DashboardControls);
expect(dashboardControls.state.variableControls[1]).toBeInstanceOf(AdHocFilterSet);
expect((dashboardControls.state.variableControls[1] as AdHocFilterSet).state.name).toBe('CoolFilters');
expect(dashboardControls.state.timeControls).toHaveLength(2);
expect(dashboardControls.state.timeControls[0]).toBeInstanceOf(SceneTimePicker);
expect(dashboardControls.state.timeControls[1]).toBeInstanceOf(SceneRefreshPicker);
@@ -789,6 +790,60 @@ describe('transformSaveModelToScene', () => {
});
});
it('should migrate adhoc variable', () => {
const variable: TypedVariableModel = {
id: 'adhoc',
global: false,
index: 0,
state: LoadingState.Done,
error: null,
name: 'adhoc',
label: 'Adhoc Label',
description: 'Adhoc Description',
type: 'adhoc',
rootStateKey: 'N4XLmH5Vz',
datasource: {
uid: 'gdev-prometheus',
type: 'prometheus',
},
filters: [
{
key: 'filterTest',
operator: '=',
value: 'test',
},
],
baseFilters: [
{
key: 'baseFilterTest',
operator: '=',
value: 'test',
},
],
hide: 0,
skipUrlSync: false,
};
const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable;
const filterVarState = migrated.state;
expect(migrated).toBeInstanceOf(AdHocFiltersVariable);
expect(filterVarState).toEqual({
key: expect.any(String),
description: 'Adhoc Description',
hide: 0,
label: 'Adhoc Label',
name: 'adhoc',
skipUrlSync: false,
type: 'adhoc',
filterExpression: 'filterTest="test"',
filters: [{ key: 'filterTest', operator: '=', value: 'test' }],
baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }],
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
applyMode: 'auto',
});
});
it.each(['system'])('should throw for unsupported (yet) variables', (type) => {
const variable = {
name: 'query0',
@@ -856,7 +911,7 @@ describe('transformSaveModelToScene', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[1]).toBeInstanceOf(
SceneDataLayerControls
);
@@ -886,7 +941,7 @@ describe('transformSaveModelToScene', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[1]).toBeInstanceOf(
SceneDataLayerControls
);
@@ -902,7 +957,7 @@ describe('transformSaveModelToScene', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });
expect(scene.state.$data).toBeInstanceOf(SceneDataLayers);
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[2]).toBeInstanceOf(
expect((scene.state.controls![0] as DashboardControls)!.state.variableControls[1]).toBeInstanceOf(
SceneDataLayerControls
);

View File

@@ -1,4 +1,4 @@
import { AdHocVariableModel, TypedVariableModel, VariableModel } from '@grafana/data';
import { TypedVariableModel } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
VizPanel,
@@ -24,9 +24,9 @@ import {
SceneDataLayers,
SceneDataLayerProvider,
SceneDataLayerControls,
AdHocFilterSet,
TextBoxVariable,
UserActionEvent,
AdHocFiltersVariable,
} from '@grafana/scenes';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { trackDashboardLoaded } from 'app/features/dashboard/utils/tracking';
@@ -173,24 +173,11 @@ function createRowFromPanelModel(row: PanelModel, content: SceneGridItemLike[]):
export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel) {
let variables: SceneVariableSet | undefined = undefined;
let layers: SceneDataLayerProvider[] = [];
let filtersSets: AdHocFilterSet[] = [];
if (oldModel.templating?.list?.length) {
const variableObjects = oldModel.templating.list
.map((v) => {
try {
if (isAdhocVariable(v)) {
filtersSets.push(
new AdHocFilterSet({
name: v.name,
datasource: v.datasource,
filters: v.filters ?? [],
baseFilters: v.baseFilters ?? [],
})
);
return null;
}
return createSceneVariableFromVariableModel(v);
} catch (err) {
console.error(err);
@@ -282,7 +269,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
: undefined,
controls: [
new DashboardControls({
variableControls: [new VariableValueSelectors({}), ...filtersSets, new SceneDataLayerControls()],
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
timeControls: [
new SceneTimePicker({}),
new SceneRefreshPicker({
@@ -305,6 +292,18 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
name: variable.name,
label: variable.label,
};
if (variable.type === 'adhoc') {
return new AdHocFiltersVariable({
...commonProperties,
description: variable.description,
skipUrlSync: variable.skipUrlSync,
hide: variable.hide,
datasource: variable.datasource,
applyMode: 'auto',
filters: variable.filters ?? [],
baseFilters: variable.baseFilters ?? [],
});
}
if (variable.type === 'custom') {
return new CustomVariable({
...commonProperties,
@@ -480,8 +479,6 @@ export function buildGridItemForPanel(panel: PanelModel): SceneGridItemLike {
});
}
const isAdhocVariable = (v: VariableModel): v is AdHocVariableModel => v.type === 'adhoc';
const getLimitedDescriptionReporter = () => {
const reportedPanels: string[] = [];

View File

@@ -11,7 +11,6 @@ import {
VizPanel,
SceneDataTransformer,
SceneVariableSet,
AdHocFilterSet,
LocalValueVariable,
SceneRefreshPicker,
} from '@grafana/scenes';
@@ -101,17 +100,6 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
refreshIntervals = control.state.intervals;
}
}
const variableControls = state.controls[0].state.variableControls;
for (const control of variableControls) {
if (control instanceof AdHocFilterSet) {
variables.push({
name: control.state.name!,
type: 'adhoc',
datasource: control.state.datasource,
});
}
}
}
if (state.$behaviors) {

View File

@@ -10,7 +10,14 @@ import {
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { SceneVariableSet, CustomVariable, SceneGridItem, SceneGridLayout, VizPanel } from '@grafana/scenes';
import {
SceneVariableSet,
CustomVariable,
SceneGridItem,
SceneGridLayout,
VizPanel,
AdHocFiltersVariable,
} from '@grafana/scenes';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
@@ -97,11 +104,16 @@ describe('VariablesEditView', () => {
query: 'test3, test4, $customVar',
value: 'test3',
},
{
type: 'adhoc',
name: 'adhoc',
},
];
const variables = variableView.getVariables();
expect(variables).toHaveLength(2);
expect(variables).toHaveLength(3);
expect(variables[0].state).toMatchObject(expectedVariables[0]);
expect(variables[1].state).toMatchObject(expectedVariables[1]);
expect(variables[2].state).toMatchObject(expectedVariables[2]);
});
});
@@ -117,7 +129,7 @@ describe('VariablesEditView', () => {
const variables = variableView.getVariables();
const variable = variables[0];
variableView.onDuplicated(variable.state.name);
expect(variableView.getVariables()).toHaveLength(3);
expect(variableView.getVariables()).toHaveLength(4);
expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar');
});
@@ -125,7 +137,7 @@ describe('VariablesEditView', () => {
const variableIdentifier = 'customVar';
variableView.onDuplicated(variableIdentifier);
variableView.onDuplicated(variableIdentifier);
expect(variableView.getVariables()).toHaveLength(4);
expect(variableView.getVariables()).toHaveLength(5);
expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar_1');
expect(variableView.getVariables()[2].state.name).toBe('copy_of_customVar');
});
@@ -133,7 +145,7 @@ describe('VariablesEditView', () => {
it('should delete a variable', () => {
const variableIdentifier = 'customVar';
variableView.onDelete(variableIdentifier);
expect(variableView.getVariables()).toHaveLength(1);
expect(variableView.getVariables()).toHaveLength(2);
expect(variableView.getVariables()[0].state.name).toBe('customVar2');
});
@@ -147,7 +159,7 @@ describe('VariablesEditView', () => {
it('should keep the same order of variables with invalid indexes', () => {
const fromIndex = 0;
const toIndex = 2;
const toIndex = 3;
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@@ -163,11 +175,11 @@ describe('VariablesEditView', () => {
const previousVariable = variableView.getVariables()[1] as CustomVariable;
variableView.onEdit('customVar2');
variableView.onTypeChange('constant');
expect(variableView.getVariables()).toHaveLength(2);
variableView.onTypeChange('adhoc');
expect(variableView.getVariables()).toHaveLength(3);
const variable = variableView.getVariables()[1];
expect(variable).not.toBe(previousVariable);
expect(variable.state.type).toBe('constant');
expect(variable.state.type).toBe('adhoc');
// Values to be kept between the old and new variable
expect(variable.state.name).toEqual(previousVariable.state.name);
@@ -184,9 +196,9 @@ describe('VariablesEditView', () => {
it('should add default new query variable when onAdd is called', () => {
variableView.onAdd();
expect(variableView.getVariables()).toHaveLength(3);
expect(variableView.getVariables()[2].state.name).toBe('query0');
expect(variableView.getVariables()[2].state.type).toBe('query');
expect(variableView.getVariables()).toHaveLength(4);
expect(variableView.getVariables()[3].state.name).toBe('query0');
expect(variableView.getVariables()[3].state.type).toBe('query');
});
afterEach(() => {
@@ -266,6 +278,17 @@ async function buildTestScene() {
value: '$customVar',
text: '$customVar',
}),
new AdHocFiltersVariable({
type: 'adhoc',
name: 'adhoc',
filters: [
{
key: 'test',
operator: '=',
value: 'testValue',
},
],
}),
],
}),
body: new SceneGridLayout({

View File

@@ -1,14 +1,7 @@
import React from 'react';
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import {
SceneComponentProps,
SceneObjectBase,
SceneVariable,
SceneVariables,
sceneGraph,
AdHocFilterSet,
} from '@grafana/scenes';
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
@@ -46,7 +39,7 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
return variables.findIndex((variable) => variable.state.name === identifier);
};
private replaceEditVariable = (newVariable: SceneVariable | AdHocFilterSet) => {
private replaceEditVariable = (newVariable: SceneVariable) => {
// Find the index of the variable to be deleted
const variableIndex = this.state.editIndex ?? -1;
const { variables } = this.getVariableSet().state;
@@ -58,18 +51,10 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
return;
}
if (newVariable instanceof AdHocFilterSet) {
// TODO: Update controls in adding this fiter set to the dashboard
} else {
const updatedVariables = [
...variables.slice(0, variableIndex),
newVariable,
...variables.slice(variableIndex + 1),
];
const updatedVariables = [...variables.slice(0, variableIndex), newVariable, ...variables.slice(variableIndex + 1)];
// Update the state or the variables array
this.getVariableSet().setState({ variables: updatedVariables });
}
// Update the state or the variables array
this.getVariableSet().setState({ variables: updatedVariables });
};
public onDelete = (identifier: string) => {
@@ -158,12 +143,9 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
const variableIndex = variables.length;
//add the new variable to the end of the array
const defaultNewVariable = getVariableDefault(variables);
if (defaultNewVariable instanceof AdHocFilterSet) {
// TODO: Update controls in adding this fiter set to the dashboard
} else {
this.getVariableSet().setState({ variables: [...this.getVariables(), defaultNewVariable] });
this.setState({ editIndex: variableIndex });
}
this.getVariableSet().setState({ variables: [...this.getVariables(), defaultNewVariable] });
this.setState({ editIndex: variableIndex });
};
public onTypeChange = (type: EditableVariableType) => {

View File

@@ -0,0 +1,75 @@
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { AdHocVariableForm } from './AdHocVariableForm';
const defaultDatasource = mockDataSource({
name: 'Default Test Data Source',
uid: 'test-ds',
type: 'test',
});
const promDatasource = mockDataSource({
name: 'Prometheus',
uid: 'prometheus',
type: 'prometheus',
});
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
getDataSourceSrv: () => ({
get: async () => defaultDatasource,
getList: () => [defaultDatasource, promDatasource],
getInstanceSettings: () => ({ ...defaultDatasource }),
}),
}));
describe('AdHocVariableForm', () => {
it('should render the form with the provided data source', async () => {
const onDataSourceChange = jest.fn();
const { renderer } = await setup({
datasource: defaultDatasource,
onDataSourceChange,
infoText: 'Test Info',
});
const dataSourcePicker = renderer.getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect
);
const infoText = renderer.getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText
);
expect(dataSourcePicker).toBeInTheDocument();
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
expect(infoText).toBeInTheDocument();
expect(infoText).toHaveTextContent('Test Info');
});
it('should call the onDataSourceChange callback when the data source is changed', async () => {
const onDataSourceChange = jest.fn();
const { renderer, user } = await setup({
datasource: defaultDatasource,
onDataSourceChange,
infoText: 'Test Info',
});
// Simulate changing the data source
await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2));
await user.click(renderer.getByText(/prom/i));
expect(onDataSourceChange).toHaveBeenCalledTimes(1);
expect(onDataSourceChange).toHaveBeenCalledWith(promDatasource, undefined);
});
});
async function setup(props?: React.ComponentProps<typeof AdHocVariableForm>) {
return {
renderer: await act(() => render(<AdHocVariableForm onDataSourceChange={jest.fn()} {...props} />)),
user: userEvent.setup(),
};
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { DataSourceInstanceSettings } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourceRef } from '@grafana/schema';
import { Alert, Field } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { VariableLegend } from './VariableLegend';
interface AdHocVariableFormProps {
datasource?: DataSourceRef;
onDataSourceChange: (dsSettings: DataSourceInstanceSettings) => void;
infoText?: string;
}
export function AdHocVariableForm({ datasource, infoText, onDataSourceChange }: AdHocVariableFormProps) {
return (
<>
<VariableLegend>Ad-hoc options</VariableLegend>
<Field label="Data source" htmlFor="data-source-picker">
<DataSourcePicker current={datasource} onChange={onDataSourceChange} width={30} variables={true} noDefault />
</Field>
{infoText ? (
<Alert
title={infoText}
severity="info"
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,122 @@
import { render, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { of } from 'rxjs';
import {
FieldType,
LoadingState,
PanelData,
VariableSupportType,
getDefaultTimeRange,
toDataFrame,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { setRunRequest } from '@grafana/runtime/src';
import { AdHocFiltersVariable } from '@grafana/scenes';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
import { AdHocFiltersVariableEditor } from './AdHocFiltersVariableEditor';
const defaultDatasource = mockDataSource({
name: 'Default Test Data Source',
uid: 'test-ds',
type: 'test',
});
const promDatasource = mockDataSource({
name: 'Prometheus',
uid: 'prometheus',
type: 'prometheus',
});
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
getDataSourceSrv: () => ({
get: async () => ({
...defaultDatasource,
variables: {
getType: () => VariableSupportType.Custom,
query: jest.fn(),
editor: jest.fn().mockImplementation(LegacyVariableQueryEditor),
},
}),
getList: () => [defaultDatasource, promDatasource],
getInstanceSettings: () => ({ ...defaultDatasource }),
}),
}));
const runRequestMock = jest.fn().mockReturnValue(
of<PanelData>({
state: LoadingState.Done,
series: [
toDataFrame({
fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }],
}),
],
timeRange: getDefaultTimeRange(),
})
);
setRunRequest(runRequestMock);
describe('AdHocFiltersVariableEditor', () => {
it('renders AdHocVariableForm with correct props', async () => {
const { renderer } = await setup();
const dataSourcePicker = renderer.getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect
);
const infoText = renderer.getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.infoText
);
expect(dataSourcePicker).toBeInTheDocument();
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
expect(infoText).toBeInTheDocument();
expect(infoText).toHaveTextContent('This data source does not support ad hoc filters yet.');
});
it('should update the variable data source when data source picker is changed', async () => {
const { renderer, variable, user } = await setup();
// Simulate changing the data source
await user.click(renderer.getByTestId(selectors.components.DataSourcePicker.inputV2));
await user.click(renderer.getByText(/prom/i));
expect(variable.state.datasource).toEqual({ uid: 'prometheus', type: 'prometheus' });
});
});
async function setup(props?: React.ComponentProps<typeof AdHocFiltersVariableEditor>) {
const onRunQuery = jest.fn();
const variable = new AdHocFiltersVariable({
name: 'adhocVariable',
type: 'adhoc',
label: 'Ad hoc filters',
description: 'Ad hoc filters are applied automatically to all queries that target this data source',
datasource: { uid: defaultDatasource.uid, type: defaultDatasource.type },
filters: [
{
key: 'test',
operator: '=',
value: 'testValue',
},
],
baseFilters: [
{
key: 'baseTest',
operator: '=',
value: 'baseTestValue',
},
],
});
return {
renderer: await act(() =>
render(<AdHocFiltersVariableEditor variable={variable} onRunQuery={onRunQuery} {...props} />)
),
variable,
user: userEvent.setup(),
mocks: { onRunQuery },
};
}

View File

@@ -1,12 +1,40 @@
import React from 'react';
import { useAsync } from 'react-use';
import { DataSourceInstanceSettings } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { AdHocFiltersVariable } from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { AdHocVariableForm } from '../components/AdHocVariableForm';
interface AdHocFiltersVariableEditorProps {
variable: AdHocFiltersVariable;
onChange: (variable: AdHocFiltersVariable) => void;
onRunQuery: (variable: AdHocFiltersVariable) => void;
}
export function AdHocFiltersVariableEditor(props: AdHocFiltersVariableEditorProps) {
return <div>AdHocFiltersVariableEditor</div>;
const { variable } = props;
const datasourceRef = variable.useState().datasource ?? undefined;
const { value: datasourceSettings } = useAsync(async () => {
return await getDataSourceSrv().get(datasourceRef);
}, [datasourceRef]);
const message = datasourceSettings?.getTagKeys
? 'Ad hoc filters are applied automatically to all queries that target this data source'
: 'This data source does not support ad hoc filters yet.';
const onDataSourceChange = (ds: DataSourceInstanceSettings) => {
const dsRef: DataSourceRef = {
uid: ds.uid,
type: ds.type,
};
variable.setState({
datasource: dsRef,
});
};
return <AdHocVariableForm datasource={datasourceRef} infoText={message} onDataSourceChange={onDataSourceChange} />;
}

View File

@@ -172,10 +172,10 @@ describe('getVariableScene', () => {
['adhoc', AdHocFiltersVariable],
['groupby', GroupByVariable],
['textbox', TextBoxVariable],
])('should return the scene variable instance for the given editable variable type', () => {
])('should return the scene variable instance for the given editable variable type', (type, instanceType) => {
const initialState = { name: 'MyVariable' };
const sceneVariable = getVariableScene('custom', initialState);
expect(sceneVariable).toBeInstanceOf(CustomVariable);
const sceneVariable = getVariableScene(type as EditableVariableType, initialState);
expect(sceneVariable).toBeInstanceOf(instanceType);
expect(sceneVariable.state.name).toBe(initialState.name);
});
});

View File

@@ -9,10 +9,10 @@ import {
IntervalVariable,
TextBoxVariable,
QueryVariable,
AdHocFilterSet,
GroupByVariable,
SceneVariable,
MultiValueVariable,
AdHocFiltersVariable,
SceneVariableState,
} from '@grafana/scenes';
import { VariableType } from '@grafana/schema';
@@ -124,8 +124,7 @@ export function getVariableScene(type: EditableVariableType, initialState: Commo
case 'datasource':
return new DataSourceVariable(initialState);
case 'adhoc':
// TODO: Initialize properly AdHocFilterSet with initialState
return new AdHocFilterSet({ name: initialState.name });
return new AdHocFiltersVariable(initialState);
case 'groupby':
return new GroupByVariable(initialState);
case 'textbox':

View File

@@ -10,7 +10,7 @@ export function getVariablesCompatibility(sceneObject: SceneObject): TypedVariab
// Sadly templateSrv.getVariables returns TypedVariableModel but sceneVariablesSetToVariables return persisted schema model
// They look close to identical (differ in what is optional in some places).
// The way templateSrv.getVariables is used it should not matter. it is mostly used to get names of all variables (for query editors).
// So type and name are important. Maybe some external data sourcess also check current value so that is also important.
// So type and name are important. Maybe some external data sources also check current value so that is also important.
// @ts-expect-error
return legacyModels;
}

View File

@@ -13,7 +13,6 @@ import {
EventBus,
ExploreLogsPanelState,
ExplorePanelsState,
FeatureState,
Field,
GrafanaTheme2,
LinkModel,
@@ -36,7 +35,6 @@ import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import {
Button,
FeatureBadge,
InlineField,
InlineFieldRow,
InlineSwitch,
@@ -654,21 +652,13 @@ class UnthemedLogs extends PureComponent<Props, State> {
</PanelChrome>
<PanelChrome
titleItems={[
config.featureToggles.logsExploreTableVisualisation
? this.state.visualisationType === 'logs'
? null
: [
<PanelChrome.TitleItem title="Experimental" key="A">
<FeatureBadge
featureState={FeatureState.beta}
tooltip="Table view is experimental and may change in future versions"
/>
</PanelChrome.TitleItem>,
<PanelChrome.TitleItem title="Feedback" key="B">
<LogsFeedback feedbackUrl="https://forms.gle/5YyKdRQJ5hzq4c289" />
</PanelChrome.TitleItem>,
]
: null,
config.featureToggles.logsExploreTableVisualisation ? (
this.state.visualisationType === 'logs' ? null : (
<PanelChrome.TitleItem title="Feedback" key="A">
<LogsFeedback feedbackUrl="https://forms.gle/5YyKdRQJ5hzq4c289" />
</PanelChrome.TitleItem>
)
) : null,
]}
title={'Logs'}
actions={

View File

@@ -94,9 +94,9 @@ const getStyles = (theme: GrafanaTheme2, height: number) => {
`,
footer: css`
height: 60px;
margin: ${theme.spacing(3)} auto;
display: flex;
justify-content: center;
align-items: center;
font-weight: ${theme.typography.fontWeightLight};
font-size: ${theme.typography.bodySmall.fontSize};
a {

View File

@@ -54,9 +54,9 @@ const getStyles = (theme: GrafanaTheme2) => {
`,
footer: css`
height: 60px;
margin-top: ${theme.spacing(3)};
display: flex;
justify-content: center;
align-items: center;
font-weight: ${theme.typography.fontWeightLight};
font-size: ${theme.typography.bodySmall.fontSize};
a {

View File

@@ -9,7 +9,7 @@ import { Button, ClipboardButton, JSONFormatter, LoadingPlaceholder, Stack } fro
import { Trans } from 'app/core/internationalization';
import { backendSrv } from 'app/core/services/backend_srv';
import { getPanelInspectorStyles } from './styles';
import { getPanelInspectorStyles2 } from './styles';
interface ExecutedQueryInfo {
refId: string;
@@ -212,7 +212,7 @@ export class QueryInspector extends PureComponent<Props, State> {
const { allNodesExpanded, executedQueries, response } = this.state;
const { onRefreshQuery, data } = this.props;
const openNodes = this.getNrOfOpenNodes();
const styles = getPanelInspectorStyles();
const styles = getPanelInspectorStyles2(config.theme2);
const haveData = Object.keys(response).length > 0;
const isLoading = data.state === LoadingState.Loading;

View File

@@ -28,9 +28,9 @@ export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphAc
const labelName = Object.keys(labels)[0];
variable.state.set.setState({
variable.setState({
filters: [
...variable.state.set.state.filters,
...variable.state.filters,
{
key: labelName,
operator: '=',

View File

@@ -11,7 +11,7 @@ export function getLabelOptions(scenObject: SceneObject, variable: QueryVariable
return [];
}
const filters = labelFilters.state.set.state.filters;
const filters = labelFilters.state.filters;
for (const option of variable.getOptionsForSelect()) {
const filterExists = filters.find((f) => f.key === option.value);

View File

@@ -137,7 +137,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
// Add metric to adhoc filters baseFilter
const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this);
if (filterVar instanceof AdHocFiltersVariable) {
filterVar.state.set.setState({
filterVar.setState({
baseFilters: getBaseFiltersForMetric(evt.payload),
});
}
@@ -208,7 +208,7 @@ function getVariableSet(initialDS?: string, metric?: string, initialFilters?: Ad
value: initialDS,
pluginId: metric === LOGS_METRIC ? 'loki' : 'prometheus',
}),
AdHocFiltersVariable.create({
new AdHocFiltersVariable({
name: VAR_FILTERS,
datasource: trailDS,
layout: 'vertical',

View File

@@ -23,7 +23,7 @@ export function DataTrailCard({ trail, onSelect, onDelete }: Props) {
return null;
}
const filters = filtersVariable.state.set.state.filters;
const filters = filtersVariable.state.filters;
const dsValue = getDataSource(trail);
return (

View File

@@ -2,6 +2,13 @@ import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared';
import { SerializedTrail, getTrailStore } from './TrailStore';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getTemplateSrv: () => ({
getAdhocFilters: jest.fn().mockReturnValue([{ key: 'origKey', operator: '=', value: '' }]),
}),
}));
describe('TrailStore', () => {
beforeAll(() => {
let localStore: Record<string, string> = {};

View File

@@ -1,90 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
import { adHocBuilder } from '../shared/testing/builders';
import { AdHocVariableEditorUnConnected as AdHocVariableEditor } from './AdHocVariableEditor';
const promDsMock = mockDataSource({
name: 'Prometheus',
type: DataSourceType.Prometheus,
});
const lokiDsMock = mockDataSource({
name: 'Loki',
type: DataSourceType.Loki,
});
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
return {
getDataSourceSrv: () => ({
get: () => {
return Promise.resolve(promDsMock);
},
getList: () => [promDsMock, lokiDsMock],
getInstanceSettings: (v: string) => {
if (v === 'Prometheus') {
return promDsMock;
}
return lokiDsMock;
},
}),
};
});
const props = {
extended: {
dataSources: [
{ text: 'Prometheus', value: null }, // default datasource
{ text: 'Loki', value: { type: 'loki-ds', uid: 'abc' } },
],
} as ComponentProps<typeof AdHocVariableEditor>['extended'],
variable: adHocBuilder().withId('adhoc').withRootStateKey('key').withName('adhoc').build(),
onPropChange: jest.fn(),
// connected actions
initAdHocVariableEditor: jest.fn(),
changeVariableDatasource: jest.fn(),
};
describe('AdHocVariableEditor', () => {
beforeEach(() => {
props.changeVariableDatasource.mockReset();
});
it('has a datasource select menu', async () => {
render(<AdHocVariableEditor {...props} />);
expect(await screen.getByTestId(selectors.components.DataSourcePicker.container)).toBeInTheDocument();
});
it('calls the callback when changing the datasource', async () => {
render(<AdHocVariableEditor {...props} />);
const selectEl = screen
.getByTestId(selectors.components.DataSourcePicker.container)
.getElementsByTagName('input')[0];
await userEvent.click(selectEl);
await userEvent.click(screen.getByText('Loki'));
expect(props.changeVariableDatasource).toBeCalledWith(
{ type: 'adhoc', id: 'adhoc', rootStateKey: 'key' },
{ type: 'loki', uid: 'mock-ds-3' }
);
});
it('renders informational text', () => {
const extended = {
...props.extended,
infoText: "Here's a message that should help you",
};
render(<AdHocVariableEditor {...props} extended={extended} />);
const alert = screen.getByText("Here's a message that should help you");
expect(alert).toBeInTheDocument();
});
});

View File

@@ -2,11 +2,9 @@ import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
import { Alert, Field } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { AdHocVariableForm } from 'app/features/dashboard-scene/settings/variables/components/AdHocVariableForm';
import { StoreState } from 'app/types';
import { VariableLegend } from '../../dashboard-scene/settings/variables/components/VariableLegend';
import { initialVariableEditorState } from '../editor/reducer';
import { getAdhocVariableEditorState } from '../editor/selectors';
import { VariableEditorProps } from '../editor/types';
@@ -58,23 +56,13 @@ export class AdHocVariableEditorUnConnected extends PureComponent<Props> {
render() {
const { variable, extended } = this.props;
const infoText = extended?.infoText ?? null;
return (
<>
<VariableLegend>Ad-hoc options</VariableLegend>
<Field label="Data source" htmlFor="data-source-picker">
<DataSourcePicker
current={variable.datasource}
onChange={this.onDatasourceChanged}
width={30}
variables={true}
noDefault
/>
</Field>
{infoText ? <Alert title={infoText} severity="info" /> : null}
</>
<AdHocVariableForm
datasource={variable.datasource ?? undefined}
onDataSourceChange={this.onDatasourceChanged}
infoText={extended?.infoText}
/>
);
}
}

View File

@@ -38,7 +38,7 @@ import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@gr
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { LokiOptions } from './_importedDependencies/datasources/loki/types';
import { PromQuery, PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types';
import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import {
defaultTableFilter,
@@ -55,10 +55,11 @@ import TempoLanguageProvider from './language_provider';
import { createTableFrameFromMetricsSummaryQuery, emptyResponse, MetricsSummary } from './metricsSummary';
import {
createTableFrameFromSearch,
formatTraceQLMetrics,
formatTraceQLResponse,
transformFromOTLP as transformFromOTEL,
transformTrace,
transformTraceList,
formatTraceQLResponse,
} from './resultTransformer';
import { doTempoChannelStream } from './streaming';
import { SearchQueryParams, TempoJsonData, TempoQuery } from './types';
@@ -337,9 +338,8 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
try {
const appliedQuery = this.applyVariables(targets.traceql[0], options.scopedVars);
const queryValue = appliedQuery?.query || '';
const hexOnlyRegex = /^[0-9A-Fa-f]*$/;
// Check whether this is a trace ID or traceQL query by checking if it only contains hex characters
if (queryValue.trim().match(hexOnlyRegex)) {
if (this.isTraceIdQuery(queryValue)) {
// There's only hex characters so let's assume that this is a trace ID
reportInteraction('grafana_traces_traceID_queried', {
datasourceType: 'tempo',
@@ -350,39 +350,23 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
subQueries.push(this.handleTraceIdQuery(options, targets.traceql));
} else {
reportInteraction('grafana_traces_traceql_queried', {
datasourceType: 'tempo',
app: options.app ?? '',
grafana_version: config.buildInfo.version,
query: queryValue ?? '',
streaming: config.featureToggles.traceQLStreaming,
});
if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) {
subQueries.push(this.handleStreamingSearch(options, targets.traceql, queryValue));
if (this.isTraceQlMetricsQuery(queryValue)) {
reportInteraction('grafana_traces_traceql_metrics_queried', {
datasourceType: 'tempo',
app: options.app ?? '',
grafana_version: config.buildInfo.version,
query: queryValue ?? '',
});
subQueries.push(this.handleTraceQlMetricsQuery(options, queryValue));
} else {
subQueries.push(
this._request('/api/search', {
q: queryValue,
limit: options.targets[0].limit ?? DEFAULT_LIMIT,
spss: options.targets[0].spss ?? DEFAULT_SPSS,
start: options.range.from.unix(),
end: options.range.to.unix(),
}).pipe(
map((response) => {
return {
data: formatTraceQLResponse(
response.data.traces,
this.instanceSettings,
targets.traceql[0].tableType
),
};
}),
catchError((err) => {
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
})
)
);
reportInteraction('grafana_traces_traceql_queried', {
datasourceType: 'tempo',
app: options.app ?? '',
grafana_version: config.buildInfo.version,
query: queryValue ?? '',
streaming: config.featureToggles.traceQLStreaming,
});
subQueries.push(this.handleTraceQlQuery(options, targets, queryValue));
}
}
} catch (error) {
@@ -497,6 +481,19 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
return merge(...subQueries);
}
isTraceQlMetricsQuery(query: string): boolean {
// Check whether this is a metrics query by checking if it contains a metrics function
const metricsFnRegex =
/\|\s*(rate|count_over_time|avg_over_time|max_over_time|min_over_time|quantile_over_time)\s*\(/;
return !!query.trim().match(metricsFnRegex);
}
isTraceIdQuery(query: string): boolean {
const hexOnlyRegex = /^[0-9A-Fa-f]*$/;
// Check whether this is a trace ID or traceQL query by checking if it only contains hex characters
return !!query.trim().match(hexOnlyRegex);
}
applyTemplateVariables(query: TempoQuery, scopedVars: ScopedVars) {
return this.applyVariables(query, scopedVars);
}
@@ -640,6 +637,55 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
);
}
handleTraceQlQuery = (
options: DataQueryRequest<TempoQuery>,
targets: {
[type: string]: TempoQuery[];
},
queryValue: string
): Observable<DataQueryResponse> => {
if (config.featureToggles.traceQLStreaming && this.isFeatureAvailable(FeatureName.streaming)) {
return this.handleStreamingSearch(options, targets.traceql, queryValue);
} else {
return this._request('/api/search', {
q: queryValue,
limit: options.targets[0].limit ?? DEFAULT_LIMIT,
spss: options.targets[0].spss ?? DEFAULT_SPSS,
start: options.range.from.unix(),
end: options.range.to.unix(),
}).pipe(
map((response) => {
return {
data: formatTraceQLResponse(response.data.traces, this.instanceSettings, targets.traceql[0].tableType),
};
}),
catchError((err) => {
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
})
);
}
};
handleTraceQlMetricsQuery = (
options: DataQueryRequest<TempoQuery>,
queryValue: string
): Observable<DataQueryResponse> => {
return this._request('/api/metrics/query_range', {
query: queryValue,
start: options.range.from.unix(),
end: options.range.to.unix(),
}).pipe(
map((response) => {
return {
data: formatTraceQLMetrics(response.data),
};
}),
catchError((err) => {
return of({ error: { message: getErrorMessage(err.data.message) }, data: [] });
})
);
};
traceIdQueryRequest(options: DataQueryRequest<TempoQuery>, targets: TempoQuery[]): DataQueryRequest<TempoQuery> {
const request = {
...options,

View File

@@ -3,32 +3,41 @@ import { collectorTypes } from '@opentelemetry/exporter-collector';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import {
createDataFrame,
createTheme,
DataFrame,
DataFrameDTO,
DataLink,
DataLinkConfigOrigin,
DataQueryResponse,
DataSourceInstanceSettings,
DataSourceJsonData,
Field,
FieldDTO,
FieldType,
getDisplayProcessor,
Labels,
MutableDataFrame,
toDataFrame,
TraceKeyValuePair,
TraceLog,
TraceSpanReference,
TraceSpanRow,
FieldDTO,
createDataFrame,
getDisplayProcessor,
createTheme,
DataFrameDTO,
toDataFrame,
DataLink,
DataSourceJsonData,
Field,
DataLinkConfigOrigin,
} from '@grafana/data';
import { TraceToProfilesData } from '@grafana/o11y-ds-frontend';
import { getDataSourceSrv } from '@grafana/runtime';
import { SearchTableType } from './dataquery.gen';
import { createGraphFrames } from './graphTransform';
import { Span, SpanAttributes, Spanset, TempoJsonData, TraceSearchMetadata } from './types';
import {
ProtoValue,
Span,
SpanAttributes,
Spanset,
TempoJsonData,
TraceqlMetricsResponse,
TraceSearchMetadata,
} from './types';
export function createTableFrame(
logsFrame: DataFrame | DataFrameDTO,
@@ -623,6 +632,46 @@ function transformToTraceData(data: TraceSearchMetadata) {
};
}
const metricsValueToString = (value: ProtoValue): string => {
return '' + (value.stringValue || value.intValue || value.doubleValue || value.boolValue || '');
};
export function formatTraceQLMetrics(data: TraceqlMetricsResponse) {
const frames = data.series.map((series) => {
const labels: Labels = {};
series.labels.forEach((label) => {
labels[label.key] = metricsValueToString(label.value);
});
const displayName =
series.labels.length === 1
? metricsValueToString(series.labels[0].value)
: `{${series.labels.map((label) => `${label.key}=${metricsValueToString(label.value)}`).join(',')}}`;
return createDataFrame({
refId: series.promLabels,
fields: [
{
name: 'time',
type: FieldType.time,
values: series.samples.map((sample) => parseInt(sample.timestampMs, 10)),
},
{
name: series.promLabels,
labels,
type: FieldType.number,
values: series.samples.map((sample) => sample.value),
config: {
displayNameFromDS: displayName,
},
},
],
meta: {
preferredVisualisationType: 'graph',
},
});
});
return frames;
}
export function formatTraceQLResponse(
data: TraceSearchMetadata[],
instanceSettings: DataSourceInstanceSettings,
@@ -886,10 +935,6 @@ export function createTableFrameFromTraceQlQueryAsSpans(
* @returns the spansets of the trace, if existing
*/
const getSpanSets = (trace: TraceSearchMetadata): Spanset[] => {
if (trace.spanSets && trace.spanSet) {
console.warn('Both `spanSets` and `spanSet` are set. `spanSet` will be ignored');
}
return trace.spanSets || (trace.spanSet ? [trace.spanSet] : []);
};

View File

@@ -119,3 +119,32 @@ export type Scope = {
name: string;
tags: string[];
};
// Maps to QueryRangeResponse of tempopb https://github.com/grafana/tempo/blob/cfda98fc5cb0777963f41e0949b9ad2d24b4b5b8/pkg/tempopb/tempo.proto#L360
export type TraceqlMetricsResponse = {
series: MetricsSeries[];
metrics: SearchMetrics;
};
export type MetricsSeries = {
labels: MetricsSeriesLabel[];
samples: MetricsSeriesSample[];
promLabels: string;
};
export type MetricsSeriesLabel = {
key: string;
value: ProtoValue;
};
export type ProtoValue = {
stringValue?: string;
intValue?: string;
boolValue?: boolean;
doubleValue?: string;
};
export type MetricsSeriesSample = {
timestampMs: string;
value: number;
};

View File

@@ -2,7 +2,7 @@
global variables
"""
grabpl_version = "v3.0.47"
grabpl_version = "v3.0.50"
golang_version = "1.21.6"
# nodejs_version should match what's in ".nvmrc", but without the v prefix.

View File

@@ -3833,6 +3833,15 @@ __metadata:
languageName: node
linkType: hard
"@grafana/lezer-logql@npm:0.2.3":
version: 0.2.3
resolution: "@grafana/lezer-logql@npm:0.2.3"
peerDependencies:
"@lezer/lr": ^1.0.0
checksum: 10/a7f2f07d328d3e8c3d5cd31c5a0ea9379beeb7d65def078bae159298814d92881d978b088f05eff5148c8e26528de84a6fb6add8edf7bb383863fa51e9bc5f56
languageName: node
linkType: hard
"@grafana/lezer-traceql@npm:0.0.14":
version: 0.0.14
resolution: "@grafana/lezer-traceql@npm:0.0.14"
@@ -4053,9 +4062,9 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes@npm:^2.6.5":
version: 2.6.5
resolution: "@grafana/scenes@npm:2.6.5"
"@grafana/scenes@npm:^3.2.1":
version: 3.2.1
resolution: "@grafana/scenes@npm:3.2.1"
dependencies:
"@grafana/e2e-selectors": "npm:10.0.2"
react-grid-layout: "npm:1.3.4"
@@ -4067,7 +4076,7 @@ __metadata:
"@grafana/runtime": 10.0.3
"@grafana/schema": 10.0.3
"@grafana/ui": 10.0.3
checksum: 10/68fe91a5a0c8f80b679126f3525b74b29ce3f9ad92bc558eaaf39693235137348b90796c2b02aa7c1c7929586e60df6024e139993a416ef37d4c875e548dc855
checksum: 10/5e93c0dcdfbd7cfed977d650fb0744c9b8107b1c92af2ae6d6e9f2f61bb9ba7f3cb5ad394fa3389aaef0d892a18c6a12fedf5454a4210bdcc6797a272ddbc625
languageName: node
linkType: hard
@@ -18250,12 +18259,12 @@ __metadata:
"@grafana/faro-web-sdk": "npm:^1.3.6"
"@grafana/flamegraph": "workspace:*"
"@grafana/google-sdk": "npm:0.1.2"
"@grafana/lezer-logql": "npm:0.2.2"
"@grafana/lezer-logql": "npm:0.2.3"
"@grafana/monaco-logql": "npm:^0.0.7"
"@grafana/o11y-ds-frontend": "workspace:*"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:^2.6.5"
"@grafana/scenes": "npm:^3.2.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^1.3.0-rc1"