((props) => {
>
);
});
-
+const getPromQueryBuilderStyles = () => ({
+ addaptToParent: css({
+ maxWidth: '100%',
+ }),
+});
PromQueryBuilder.displayName = 'PromQueryBuilder';
diff --git a/packages/grafana-ui/src/components/Forms/Checkbox.tsx b/packages/grafana-ui/src/components/Forms/Checkbox.tsx
index f5250844fff..3e5e2693f80 100644
--- a/packages/grafana-ui/src/components/Forms/Checkbox.tsx
+++ b/packages/grafana-ui/src/components/Forms/Checkbox.tsx
@@ -113,10 +113,10 @@ export const getCheckboxStyles = (theme: GrafanaTheme2, invalid = false) => {
content: '""',
position: 'absolute',
zIndex: 2,
- left: '4px',
+ left: theme.spacing(0.5),
top: 0,
- width: '6px',
- height: '12px',
+ width: theme.spacing(0.75),
+ height: theme.spacing(1.5),
border: `solid ${theme.colors.primary.contrastText}`,
borderWidth: '0 3px 3px 0',
transform: 'rotate(45deg)',
diff --git a/packages/grafana-ui/src/components/Forms/InlineField.tsx b/packages/grafana-ui/src/components/Forms/InlineField.tsx
index 2eb4b5c1f25..c3c95331563 100644
--- a/packages/grafana-ui/src/components/Forms/InlineField.tsx
+++ b/packages/grafana-ui/src/components/Forms/InlineField.tsx
@@ -98,6 +98,7 @@ const getStyles = (theme: GrafanaTheme2, grow?: boolean, shrink?: boolean) => {
position: 'relative',
flex: `${grow ? 1 : 0} ${shrink ? 1 : 0} auto`,
margin: `0 ${theme.spacing(0.5)} ${theme.spacing(0.5)} 0`,
+ maxWidth: '100%',
}),
childContainer: css({
flex: `${grow ? 1 : 0} ${shrink ? 1 : 0} auto`,
diff --git a/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx b/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx
index f77222a04d3..eab09f2480d 100644
--- a/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx
+++ b/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx
@@ -27,6 +27,7 @@ const getStyles = (theme: GrafanaTheme2) => {
flexWrap: 'wrap',
alignContent: 'flex-start',
rowGap: theme.spacing(0.5),
+ maxWidth: '100%',
}),
};
};
diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx
index 5bdfb00a682..31e46fd5580 100644
--- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx
+++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx
@@ -221,6 +221,7 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
+ justifyContent: 'center',
padding: theme.spacing(0.5, 2),
minHeight: theme.spacing(4),
margin: 0,
diff --git a/packages/grafana-ui/src/components/Select/SelectContainer.tsx b/packages/grafana-ui/src/components/Select/SelectContainer.tsx
index cdac2582193..2d1eb47820c 100644
--- a/packages/grafana-ui/src/components/Select/SelectContainer.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectContainer.tsx
@@ -50,7 +50,7 @@ const getSelectContainerStyles = (theme: GrafanaTheme2, focused: boolean, disabl
flexWrap: 'wrap',
alignItems: 'stretch',
justifyContent: 'space-between',
- minHeight: '32px',
+ minHeight: theme.spacing(theme.components.height.md),
height: 'auto',
maxWidth: '100%',
diff --git a/packages/grafana-ui/src/components/Switch/Switch.tsx b/packages/grafana-ui/src/components/Switch/Switch.tsx
index 351daf346a7..00ed1edc7bd 100644
--- a/packages/grafana-ui/src/components/Switch/Switch.tsx
+++ b/packages/grafana-ui/src/components/Switch/Switch.tsx
@@ -80,8 +80,8 @@ InlineSwitch.displayName = 'Switch';
const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({
switch: css({
- width: '32px',
- height: '16px',
+ width: theme.spacing(4),
+ height: theme.spacing(2),
position: 'relative',
lineHeight: 1,
@@ -101,7 +101,7 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({
},
svg: {
- transform: 'translate3d(17px, -50%, 0)',
+ transform: `translate3d(${theme.spacing(2.25)}, -50%, 0)`,
background: theme.colors.primary.contrastText,
color: theme.colors.primary.main,
},
@@ -147,13 +147,14 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({
position: 'absolute',
display: 'block',
color: 'transparent',
- width: '12px',
- height: '12px',
+ width: theme.spacing(1.5),
+ height: theme.spacing(1.5),
borderRadius: theme.shape.radius.circle,
background: theme.colors.text.secondary,
boxShadow: theme.shadows.z1,
+ left: 0,
top: '50%',
- transform: 'translate3d(1px, -50%, 0)',
+ transform: `translate3d(${theme.spacing(0.25)}, -50%, 0)`,
transition: 'transform 0.2s cubic-bezier(0.19, 1, 0.22, 1)',
'@media (forced-colors: active)': {
diff --git a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx
index 07fb0ad039d..4bc11a81e5c 100644
--- a/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx
+++ b/packages/grafana-ui/src/components/TabbedContainer/TabbedContainer.tsx
@@ -34,7 +34,7 @@ export function TabbedContainer({ tabs, defaultTab, closeIconTooltip, onClose, t
setActiveTab(item.value!);
};
- const autoHeight = `calc(100% - (${theme.components.menuTabs.height}px + ${theme.spacing(1)}))`;
+ const autoHeight = `calc(100% - (${theme.spacing(theme.components.menuTabs.height)} + ${theme.spacing(1)}))`;
return (
diff --git a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx
index 99d2e8c3b18..d5d43c51cbd 100644
--- a/packages/grafana-ui/src/components/Tabs/TabsBar.tsx
+++ b/packages/grafana-ui/src/components/Tabs/TabsBar.tsx
@@ -36,8 +36,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
tabs: css({
position: 'relative',
display: 'flex',
- height: `${theme.components.menuTabs.height}px`,
- alignItems: 'center',
+ height: theme.spacing(theme.components.menuTabs.height),
+ alignItems: 'stretch',
}),
});
diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go
index 0be8336069d..3d6b9b3e974 100644
--- a/pkg/services/accesscontrol/dualwrite/collectors.go
+++ b/pkg/services/accesscontrol/dualwrite/collectors.go
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
+ "github.com/grafana/grafana/pkg/setting"
)
func teamMembershipCollector(store db.DB) legacyTupleCollector {
@@ -394,6 +395,35 @@ func rolePermissionsCollector(store db.DB) legacyTupleCollector {
}
}
+// basicRoleBindingsCollector collects role bindings for basic roles
+func anonymousRoleBindingsCollector(cfg *setting.Cfg, store db.DB) legacyTupleCollector {
+ return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
+ tuples := make(map[string]map[string]*openfgav1.TupleKey)
+ object := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(cfg.AnonymousOrgRole), "")
+ // Object should be set to delete obsolete permissions
+ tuples[object] = make(map[string]*openfgav1.TupleKey)
+
+ o, err := getOrgByName(ctx, store, cfg.AnonymousOrgName)
+ if err != nil {
+ return tuples, nil
+ }
+
+ if o.ID != orgID {
+ return tuples, nil
+ }
+
+ tuple := &openfgav1.TupleKey{
+ User: zanzana.NewTupleEntry(zanzana.TypeAnonymous, "0", ""),
+ Relation: zanzana.RelationAssignee,
+ Object: object,
+ }
+
+ tuples[tuple.Object][tuple.String()] = tuple
+
+ return tuples, nil
+ }
+}
+
func zanzanaCollector(relations []string) zanzanaTupleCollector {
return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) {
// list will use continuation token to collect all tuples for object and relation
diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go
index 930327340fb..9a72f848cba 100644
--- a/pkg/services/accesscontrol/dualwrite/reconciler.go
+++ b/pkg/services/accesscontrol/dualwrite/reconciler.go
@@ -2,6 +2,7 @@ package dualwrite
import (
"context"
+ "fmt"
"strconv"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
+ "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
)
@@ -33,7 +35,7 @@ type ZanzanaReconciler struct {
}
func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService) *ZanzanaReconciler {
- return &ZanzanaReconciler{
+ zanzanaReconciler := &ZanzanaReconciler{
cfg: cfg,
log: log.New("zanzana.reconciler"),
client: client,
@@ -90,6 +92,19 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB,
),
},
}
+
+ if cfg.AnonymousEnabled {
+ zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
+ newResourceReconciler(
+ "anonymous role binding",
+ anonymousRoleBindingsCollector(cfg, store),
+ zanzanaCollector([]string{zanzana.RelationAssignee}),
+ client,
+ ),
+ )
+ }
+
+ return zanzanaReconciler
}
// Reconcile schedules as job that will run and reconcile resources between
@@ -182,3 +197,22 @@ func (r *ZanzanaReconciler) getOrgs(ctx context.Context) ([]int64, error) {
}
return orgs, nil
}
+
+func getOrgByName(ctx context.Context, store db.DB, name string) (*org.Org, error) {
+ var orga org.Org
+ err := store.WithDbSession(ctx, func(dbSession *db.Session) error {
+ exists, err := dbSession.Where("name=?", name).Get(&orga)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return fmt.Errorf("org does not exist: %s", name)
+ }
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+ return &orga, nil
+}
diff --git a/pkg/services/authz/zanzana/common/tuple.go b/pkg/services/authz/zanzana/common/tuple.go
index cc94052438c..bc4839c0128 100644
--- a/pkg/services/authz/zanzana/common/tuple.go
+++ b/pkg/services/authz/zanzana/common/tuple.go
@@ -14,6 +14,7 @@ const (
TypeUser string = "user"
TypeServiceAccount string = "service-account"
TypeRenderService string = "render"
+ TypeAnonymous string = "anonymous"
TypeTeam string = "team"
TypeRole string = "role"
)
diff --git a/pkg/services/authz/zanzana/schema/schema_core.fga b/pkg/services/authz/zanzana/schema/schema_core.fga
index 8dd5f382279..80375ad2d4a 100644
--- a/pkg/services/authz/zanzana/schema/schema_core.fga
+++ b/pkg/services/authz/zanzana/schema/schema_core.fga
@@ -6,9 +6,11 @@ type service-account
type render
+type anonymous
+
type role
relations
- define assignee: [user, service-account, team#member, role#assignee]
+ define assignee: [user, service-account, anonymous, team#member, role#assignee]
type team
relations
diff --git a/pkg/services/authz/zanzana/zanzana.go b/pkg/services/authz/zanzana/zanzana.go
index 5160a3f83d9..cf7d74ddaf3 100644
--- a/pkg/services/authz/zanzana/zanzana.go
+++ b/pkg/services/authz/zanzana/zanzana.go
@@ -14,6 +14,7 @@ const (
TypeUser = common.TypeUser
TypeServiceAccount = common.TypeServiceAccount
TypeRenderService = common.TypeRenderService
+ TypeAnonymous = common.TypeAnonymous
TypeTeam = common.TypeTeam
TypeRole = common.TypeRole
TypeFolder = common.TypeFolder
diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go
index e3c8a13ddbd..3c45d2135e4 100644
--- a/pkg/storage/unified/client.go
+++ b/pkg/storage/unified/client.go
@@ -20,6 +20,8 @@ import (
"github.com/grafana/grafana/pkg/services/authz"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/storage/legacysql"
+ "github.com/grafana/grafana/pkg/storage/unified/federated"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql"
)
@@ -38,14 +40,32 @@ func ProvideUnifiedStorageClient(
) (resource.ResourceClient, error) {
// See: apiserver.ApplyGrafanaConfig(cfg, features, o)
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
- opts := options.StorageOptions{
+ client, err := newClient(options.StorageOptions{
StorageType: options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeLegacy))),
DataPath: apiserverCfg.Key("storage_path").MustString(filepath.Join(cfg.DataPath, "grafana-apiserver")),
Address: apiserverCfg.Key("address").MustString(""), // client address
BlobStoreURL: apiserverCfg.Key("blob_url").MustString(""),
+ }, cfg, features, db, tracer, reg, authzc, docs)
+ if err == nil {
+ // Used to get the folder stats
+ client = federated.NewFederatedClient(
+ client, // The original
+ legacysql.NewDatabaseProvider(db),
+ )
}
- ctx := context.Background()
+ return client, err
+}
+func newClient(opts options.StorageOptions,
+ cfg *setting.Cfg,
+ features featuremgmt.FeatureToggles,
+ db infraDB.DB,
+ tracer tracing.Tracer,
+ reg prometheus.Registerer,
+ authzc authz.Client,
+ docs resource.DocumentBuilderSupplier,
+) (resource.ResourceClient, error) {
+ ctx := context.Background()
switch opts.StorageType {
case options.StorageTypeFile:
if opts.DataPath == "" {
diff --git a/pkg/storage/unified/federated/client.go b/pkg/storage/unified/federated/client.go
new file mode 100644
index 00000000000..666ef7d3625
--- /dev/null
+++ b/pkg/storage/unified/federated/client.go
@@ -0,0 +1,45 @@
+package federated
+
+import (
+ "context"
+
+ "google.golang.org/grpc"
+
+ "github.com/grafana/grafana/pkg/storage/legacysql"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+func NewFederatedClient(base resource.ResourceClient, sql legacysql.LegacyDatabaseProvider) resource.ResourceClient {
+ return &federatedClient{
+ ResourceClient: base,
+ stats: &LegacyStatsGetter{
+ SQL: sql,
+ },
+ }
+}
+
+type federatedClient struct {
+ resource.ResourceClient
+
+ // Local DB for folder stats query
+ stats *LegacyStatsGetter
+}
+
+// Get the resource stats
+func (s *federatedClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
+ rsp, err := s.ResourceClient.GetStats(ctx, in, opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ // When folder stats are requested -- join in the legacy values
+ if in.Folder != "" {
+ more, err := s.stats.GetStats(ctx, in)
+ if err != nil {
+ return rsp, err
+ }
+ rsp.Stats = append(rsp.Stats, more.Stats...)
+ }
+
+ return rsp, err
+}
diff --git a/pkg/storage/unified/federated/stats.go b/pkg/storage/unified/federated/stats.go
new file mode 100644
index 00000000000..8e5f7c0033c
--- /dev/null
+++ b/pkg/storage/unified/federated/stats.go
@@ -0,0 +1,70 @@
+package federated
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/grafana/authlib/claims"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/storage/legacysql"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+// Read stats from legacy SQL
+type LegacyStatsGetter struct {
+ SQL legacysql.LegacyDatabaseProvider
+}
+
+func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resource.ResourceStatsRequest) (*resource.ResourceStatsResponse, error) {
+ info, err := claims.ParseNamespace(in.Namespace)
+ if err != nil {
+ return nil, fmt.Errorf("unable to read namespace")
+ }
+ if info.OrgID == 0 {
+ return nil, fmt.Errorf("invalid OrgID found in namespace")
+ }
+
+ helper, err := s.SQL(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rsp := &resource.ResourceStatsResponse{}
+ err = helper.DB.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ fn := func(table, where, g, r string) error {
+ count, err := sess.Table(helper.Table(table)).Where(where, info.OrgID, in.Folder).Count()
+ if err != nil {
+ return err
+ }
+ rsp.Stats = append(rsp.Stats, &resource.ResourceStatsResponse_Stats{
+ Group: g, // all legacy for now
+ Resource: r,
+ Count: count,
+ })
+ return nil
+ }
+ // Indicate that this came from the SQL tables
+ group := "sql-fallback"
+
+ // Legacy alert rule table
+ err = fn("alert_rule", "org_id=? AND dashboard_uid=?", group, "alertrules")
+ if err != nil {
+ return err
+ }
+
+ // Legacy dashboard table
+ err = fn("dashboard", "org_id=? AND folder_uid=?", group, "dashboards")
+ if err != nil {
+ return err
+ }
+
+ // Legacy folder table
+ err = fn("folder", "org_id=? AND parent_uid=?", group, "folders")
+ if err != nil {
+ return err
+ }
+ return nil
+ })
+
+ return rsp, err
+}
diff --git a/pkg/storage/unified/federated/stats_test.go b/pkg/storage/unified/federated/stats_test.go
new file mode 100644
index 00000000000..56181303632
--- /dev/null
+++ b/pkg/storage/unified/federated/stats_test.go
@@ -0,0 +1,167 @@
+package federated
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "k8s.io/apiserver/pkg/endpoints/request"
+
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/expr"
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol/testutil"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/folder"
+ "github.com/grafana/grafana/pkg/services/folder/folderimpl"
+ "github.com/grafana/grafana/pkg/services/guardian"
+ ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
+ ngalertstore "github.com/grafana/grafana/pkg/services/ngalert/store"
+ "github.com/grafana/grafana/pkg/services/quota/quotatest"
+ "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
+ "github.com/grafana/grafana/pkg/services/tag/tagimpl"
+ "github.com/grafana/grafana/pkg/services/user"
+ "github.com/grafana/grafana/pkg/storage/legacysql"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+ "github.com/grafana/grafana/pkg/tests/testsuite"
+)
+
+func TestMain(m *testing.M) {
+ testsuite.Run(m)
+}
+
+func TestDirectSQLStats(t *testing.T) {
+ db, cfg := db.InitTestDBWithCfg(t)
+ ctx := context.Background()
+
+ dashStore, err := database.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db), quotatest.New(false, nil))
+ require.NoError(t, err)
+ fakeGuardian := &guardian.FakeDashboardGuardian{
+ CanSaveValue: true,
+ CanEditUIDs: []string{},
+ CanViewUIDs: []string{},
+ }
+ guardian.MockDashboardGuardian(fakeGuardian)
+ folderPermissions, err := testutil.ProvideFolderPermissions(featuremgmt.WithFeatures(), cfg, db)
+ require.NoError(t, err)
+ fStore := folderimpl.ProvideStore(db)
+ folderSvc := folderimpl.ProvideService(fStore, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore,
+ folderimpl.ProvideDashboardFolderStore(db), db, featuremgmt.WithFeatures(), cfg, folderPermissions, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest())
+
+ // create parent folder
+
+ tempUser := &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{}}
+
+ // create folders - test2 is nested in test1
+ folder1UID := "test1"
+ now := time.Now()
+ _, err = folderSvc.Create(ctx, &folder.CreateFolderCommand{Title: "test1", UID: folder1UID, OrgID: 1, SignedInUser: tempUser})
+ require.NoError(t, err)
+ folder2UID := "test2"
+ _, err = folderSvc.Create(ctx, &folder.CreateFolderCommand{Title: "test2", UID: folder2UID, OrgID: 1, ParentUID: folder1UID, SignedInUser: tempUser})
+ require.NoError(t, err)
+
+ // create an alert rule inside of folder test2
+ ruleStore := ngalertstore.SetupStoreForTesting(t, db)
+ _, err = ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{
+ {
+ DashboardUID: &folder2UID,
+ UID: "test",
+ Title: "test",
+ OrgID: 1,
+ Data: []ngmodels.AlertQuery{
+ {
+ RefID: "A",
+ Model: json.RawMessage("{}"),
+ DatasourceUID: expr.DatasourceUID,
+ RelativeTimeRange: ngmodels.RelativeTimeRange{
+ From: ngmodels.Duration(60),
+ To: ngmodels.Duration(0),
+ },
+ },
+ },
+ Condition: "ok",
+ Updated: now,
+ NamespaceUID: "test",
+ ExecErrState: ngmodels.ExecutionErrorState(ngmodels.Alerting),
+ NoDataState: ngmodels.Alerting,
+ IntervalSeconds: 60,
+ }})
+ require.NoError(t, err)
+
+ // finally, create dashboard inside of test1
+ _, err = dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
+ Dashboard: simplejson.New(),
+ FolderUID: folder1UID,
+ OrgID: 1,
+ })
+ require.NoError(t, err)
+
+ store := &LegacyStatsGetter{
+ SQL: legacysql.NewDatabaseProvider(db),
+ }
+
+ t.Run("GetStatsForFolder1", func(t *testing.T) {
+ ctx := context.Background()
+ ctx = request.WithNamespace(ctx, "default")
+
+ stats, err := store.GetStats(ctx, &resource.ResourceStatsRequest{
+ Namespace: "default",
+ Folder: folder1UID,
+ })
+ require.NoError(t, err)
+
+ jj, _ := json.MarshalIndent(stats.Stats, "", " ")
+ require.JSONEq(t, `[
+ {
+ "group": "sql-fallback",
+ "resource": "alertrules"
+ },
+ {
+ "group": "sql-fallback",
+ "resource": "dashboards",
+ "count": 1
+ },
+ {
+ "group": "sql-fallback",
+ "resource": "folders",
+ "count": 1
+ }
+ ]`, string(jj))
+ })
+
+ t.Run("GetStatsForFolder2", func(t *testing.T) {
+ ctx := context.Background()
+ ctx = request.WithNamespace(ctx, "default")
+
+ stats, err := store.GetStats(ctx, &resource.ResourceStatsRequest{
+ Namespace: "default",
+ Folder: folder2UID,
+ })
+ require.NoError(t, err)
+
+ jj, _ := json.MarshalIndent(stats.Stats, "", " ")
+ require.JSONEq(t, `[
+ {
+ "group": "sql-fallback",
+ "resource": "alertrules",
+ "count": 1
+ },
+ {
+ "group": "sql-fallback",
+ "resource": "dashboards"
+ },
+ {
+ "group": "sql-fallback",
+ "resource": "folders"
+ }
+ ]`, string(jj))
+ })
+}
diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
index dee69d9fe5c..93db9a8ebbd 100644
--- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
+++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go
@@ -320,14 +320,22 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A
}
logLimitDisabled := backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled("azureMonitorDisableLogLimit")
+
frame, err := ResponseTableToFrame(t, query.RefID, query.Query, query.QueryType, query.ResultFormat, logLimitDisabled)
if err != nil {
return nil, err
}
+
frame = appendErrorNotice(frame, logResponse.Error)
if frame == nil {
- dataResponse := backend.DataResponse{}
- return &dataResponse, nil
+ return &backend.DataResponse{}, nil
+ }
+
+ // Ensure Meta.Custom is initialized
+ if frame.Meta.Custom == nil {
+ frame.Meta.Custom = &LogAnalyticsMeta{
+ ColumnTypes: make([]string, 0),
+ }
}
queryUrl, err := getQueryUrl(query.Query, query.Resources, dsInfo.Routes["Azure Portal"].URL, query.TimeRange)
@@ -335,30 +343,37 @@ func (e *AzureLogAnalyticsDatasource) executeQuery(ctx context.Context, query *A
return nil, err
}
- if (query.QueryType == dataquery.AzureQueryTypeAzureTraces || query.QueryType == dataquery.AzureQueryTypeTraceql) && query.ResultFormat == dataquery.ResultFormatTrace {
- frame.Meta.PreferredVisualization = data.VisTypeTrace
- }
-
- if query.ResultFormat == dataquery.ResultFormatTable {
- frame.Meta.PreferredVisualization = data.VisTypeTable
- }
-
- if query.ResultFormat == dataquery.ResultFormatLogs {
- frame.Meta.PreferredVisualization = data.VisTypeLogs
- frame.Meta.Custom = &LogAnalyticsMeta{
- ColumnTypes: frame.Meta.Custom.(*LogAnalyticsMeta).ColumnTypes,
- AzurePortalLink: queryUrl,
+ // Set the preferred visualization
+ switch query.ResultFormat {
+ case dataquery.ResultFormatTrace:
+ if query.QueryType == dataquery.AzureQueryTypeAzureTraces || query.QueryType == dataquery.AzureQueryTypeTraceql {
+ frame.Meta.PreferredVisualization = data.VisTypeTrace
}
- }
-
- if query.ResultFormat == dataquery.ResultFormatTimeSeries {
+ case dataquery.ResultFormatTable:
+ frame.Meta.PreferredVisualization = data.VisTypeTable
+ case dataquery.ResultFormatLogs:
+ frame.Meta.PreferredVisualization = data.VisTypeLogs
+ if logMeta, ok := frame.Meta.Custom.(*LogAnalyticsMeta); ok {
+ frame.Meta.Custom = &LogAnalyticsMeta{
+ ColumnTypes: logMeta.ColumnTypes,
+ AzurePortalLink: queryUrl,
+ }
+ } else {
+ frame.Meta.Custom = &LogAnalyticsMeta{
+ AzurePortalLink: queryUrl,
+ }
+ }
+ case dataquery.ResultFormatTimeSeries:
tsSchema := frame.TimeSeriesSchema()
if tsSchema.Type == data.TimeSeriesTypeLong {
wideFrame, err := data.LongToWide(frame, nil)
if err == nil {
frame = wideFrame
} else {
- frame.AppendNotices(data.Notice{Severity: data.NoticeSeverityWarning, Text: "could not convert frame to time series, returning raw table: " + err.Error()})
+ frame.AppendNotices(data.Notice{
+ Severity: data.NoticeSeverityWarning,
+ Text: "could not convert frame to time series, returning raw table: " + err.Error(),
+ })
}
}
}
diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go
index a8584dfc59a..abe98fff713 100644
--- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go
+++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/handler_checkhealth.go
@@ -44,7 +44,23 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
if errors.As(err, &opErr) {
res.Message = "Network error: Failed to connect to the server"
if opErr != nil && opErr.Err != nil {
- res.Message += fmt.Sprintf(". Error message: %s", opErr.Err.Error())
+ errMessage := opErr.Err.Error()
+ if strings.HasSuffix(opErr.Err.Error(), "no such host") {
+ errMessage = "no such host"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "unknown port") {
+ errMessage = "unknown port"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "invalid port") {
+ errMessage = "invalid port"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "missing port in address") {
+ errMessage = "missing port in address"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "invalid syntax") {
+ errMessage = "invalid syntax found in the address"
+ }
+ res.Message += fmt.Sprintf(". Error message: %s", errMessage)
}
}
if errors.Is(err, pq.ErrSSLNotSupported) {
diff --git a/pkg/tsdb/mssql/sqleng/handler_checkhealth.go b/pkg/tsdb/mssql/sqleng/handler_checkhealth.go
index ade94fe7349..133be66a70a 100644
--- a/pkg/tsdb/mssql/sqleng/handler_checkhealth.go
+++ b/pkg/tsdb/mssql/sqleng/handler_checkhealth.go
@@ -38,7 +38,23 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
if errors.As(err, &opErr) {
res.Message = "Network error: Failed to connect to the server"
if opErr != nil && opErr.Err != nil {
- res.Message += fmt.Sprintf(". Error message: %s", opErr.Err.Error())
+ errMessage := opErr.Err.Error()
+ if strings.HasSuffix(opErr.Err.Error(), "no such host") {
+ errMessage = "no such host"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "unknown port") {
+ errMessage = "unknown port"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "invalid port") {
+ errMessage = "invalid port"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "missing port in address") {
+ errMessage = "missing port in address"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "invalid syntax") {
+ errMessage = "invalid syntax found in the address"
+ }
+ res.Message += fmt.Sprintf(". Error message: %s", errMessage)
}
}
if strings.HasPrefix(err.Error(), "mssql: ") {
diff --git a/pkg/tsdb/mysql/sqleng/handler_checkhealth.go b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go
index fea2b0b8f8f..4cc7ed6abf9 100644
--- a/pkg/tsdb/mysql/sqleng/handler_checkhealth.go
+++ b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go
@@ -41,7 +41,23 @@ func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) {
if errors.As(err, &opErr) {
res.Message = "Network error: Failed to connect to the server"
if opErr != nil && opErr.Err != nil {
- res.Message += fmt.Sprintf(". Error message: %s", opErr.Err.Error())
+ errMessage := opErr.Err.Error()
+ if strings.HasSuffix(opErr.Err.Error(), "no such host") {
+ errMessage = "no such host"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "unknown port") {
+ errMessage = "unknown port"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "invalid port") {
+ errMessage = "invalid port"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "missing port in address") {
+ errMessage = "missing port in address"
+ }
+ if strings.HasSuffix(opErr.Err.Error(), "invalid syntax") {
+ errMessage = "invalid syntax found in the address"
+ }
+ res.Message += fmt.Sprintf(". Error message: %s", errMessage)
}
details["verboseMessage"] = err.Error()
details["errorDetailsLink"] = "https://grafana.com/docs/grafana/latest/datasources/mysql/#configure-the-data-source"
diff --git a/public/app/angular/panel/specs/metrics_panel_ctrl.test.ts b/public/app/angular/panel/specs/metrics_panel_ctrl.test.ts
index 04f72d46fe4..da8129dde1f 100644
--- a/public/app/angular/panel/specs/metrics_panel_ctrl.test.ts
+++ b/public/app/angular/panel/specs/metrics_panel_ctrl.test.ts
@@ -2,18 +2,12 @@ jest.mock('app/core/core', () => ({}));
jest.mock('app/core/config', () => {
return {
...jest.requireActual('app/core/config'),
- bootData: {
- user: {},
- },
panels: {
test: {
id: 'test',
name: 'test',
},
},
- config: {
- appSubUrl: 'test',
- },
};
});
diff --git a/public/app/core/components/Page/EditableTitle.tsx b/public/app/core/components/Page/EditableTitle.tsx
index 79c46f368e9..4e333288ec9 100644
--- a/public/app/core/components/Page/EditableTitle.tsx
+++ b/public/app/core/components/Page/EditableTitle.tsx
@@ -118,6 +118,7 @@ const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
display: 'flex',
gap: theme.spacing(1),
+ height: theme.spacing(theme.components.height.md),
}),
};
};
diff --git a/public/app/core/history/RichHistoryRemoteStorage.test.ts b/public/app/core/history/RichHistoryRemoteStorage.test.ts
index 31d5c726834..766712d1c0a 100644
--- a/public/app/core/history/RichHistoryRemoteStorage.test.ts
+++ b/public/app/core/history/RichHistoryRemoteStorage.test.ts
@@ -44,6 +44,9 @@ jest.mock('../services/PreferencesService', () => ({
},
}));
+// FIXME: Tests break unless plugin loader is mocked. This is likely due to a circular dependency
+jest.mock('app/features/plugins/plugin_loader', () => ({}));
+
describe('RichHistoryRemoteStorage', () => {
let storage: RichHistoryRemoteStorage;
diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts
index 3396fc0d18c..6b584776233 100644
--- a/public/app/core/reducers/root.test.ts
+++ b/public/app/core/reducers/root.test.ts
@@ -6,17 +6,6 @@ import { cleanUpAction } from '../actions/cleanUp';
import { createRootReducer } from './root';
-jest.mock('@grafana/runtime', () => ({
- ...jest.requireActual('@grafana/runtime'),
- config: {
- ...jest.requireActual('@grafana/runtime').config,
- bootData: {
- navTree: [],
- user: {},
- },
- },
-}));
-
describe('rootReducer', () => {
const rootReducer = createRootReducer();
diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts
index 75da07f4577..76da92421a9 100644
--- a/public/app/core/utils/explore.test.ts
+++ b/public/app/core/utils/explore.test.ts
@@ -4,7 +4,7 @@ import { config } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { RefreshPicker } from '@grafana/ui';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
-import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
+import { DEFAULT_RANGE } from 'app/features/explore/state/constants';
import { getVariablesUrlParams } from 'app/features/variables/getAllVariableValuesForUrl';
import { DatasourceSrvMock, MockDataSourceApi } from '../../../test/mocks/datasource_srv';
diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts
index 7da5c42871f..eecc88977ff 100644
--- a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts
@@ -3,6 +3,7 @@ import { AlertQuery } from 'app/types/unified-alerting-dto';
import {
_createDagFromQueries,
+ _getDescendants,
_getOriginsOfRefId,
fingerPrintQueries,
fingerprintGraph,
@@ -84,6 +85,20 @@ describe('getOriginsOfRefId', () => {
});
});
+describe('getDescendants', () => {
+ test('with multiple generations', () => {
+ const graph = new Graph();
+ graph.createNodes(['A', 'B', 'C', 'D', 'E', 'F', 'G']);
+ graph.link('A', 'B');
+ graph.link('B', 'G');
+ graph.link('A', 'C');
+ graph.link('C', 'D');
+ graph.link('E', 'F');
+
+ expect(_getDescendants('A', graph)).toEqual(['B', 'G', 'C', 'D']);
+ });
+});
+
describe('parseRefsFromMathExpression', () => {
const cases: Array<[string, string[]]> = [
['$A', ['A']],
diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.ts b/public/app/features/alerting/unified/components/rule-editor/dag.ts
index 2971dd66a1e..cd4583fe6e2 100644
--- a/public/app/features/alerting/unified/components/rule-editor/dag.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/dag.ts
@@ -59,6 +59,7 @@ export function parseRefsFromMathExpression(input: string): string[] {
}
export const getOriginOfRefId = memoize(_getOriginsOfRefId, (refId, graph) => refId + fingerprintGraph(graph));
+export const getDescendants = memoize(_getDescendants, (refId, graph) => refId + fingerprintGraph(graph));
export function _getOriginsOfRefId(refId: string, graph: Graph): string[] {
const node = graph.getNode(refId);
@@ -66,23 +67,45 @@ export function _getOriginsOfRefId(refId: string, graph: Graph): string[] {
const origins: Node[] = [];
// recurse through "node > inputEdges > inputNode"
- function findChildNode(node: Node) {
+ function findParentNode(node: Node) {
const inputEdges = node.inputEdges;
if (inputEdges.length > 0) {
inputEdges.forEach((edge) => {
if (edge.inputNode) {
- findChildNode(edge.inputNode);
+ findParentNode(edge.inputNode);
}
});
} else {
- origins?.push(node);
+ origins.push(node);
}
}
+ findParentNode(node);
+
+ return origins.map((origin) => origin.name);
+}
+
+// get all children (and children's children etc) from a given node
+export function _getDescendants(refId: string, graph: Graph): string[] {
+ const node = graph.getNode(refId);
+ const descendants: Node[] = [];
+
+ // recurse through "node > outputEdges > outputNode"
+ function findChildNode(node: Node) {
+ const outputEdges = node.outputEdges;
+
+ outputEdges.forEach((edge) => {
+ if (edge.outputNode) {
+ descendants.push(edge.outputNode);
+ findChildNode(edge.outputNode);
+ }
+ });
+ }
+
findChildNode(node);
- return origins.map((origin) => origin.name);
+ return descendants.map((descendant) => descendant.name);
}
// create a unique fingerprint of the DAG
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
index a54aac610c8..9406c7fe509 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
@@ -269,10 +269,10 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
// update condition too if refId was updated
if (condition === oldRefId) {
- handleSetCondition(newRefId);
+ setValue('condition', newRefId);
}
},
- [condition, queries, handleSetCondition]
+ [condition, queries, setValue]
);
const updateExpressionAndDatasource = useSetExpressionAndDataSource();
diff --git a/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts b/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts
index a4dc6e5f576..e9935f7bc91 100644
--- a/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts
+++ b/public/app/features/alerting/unified/state/AlertingQueryRunner.test.ts
@@ -216,10 +216,10 @@ describe('AlertingQueryRunner', () => {
});
});
- it('should skip hidden queries', async () => {
+ it('should skip hidden queries and descendant nodes', async () => {
const results = createFetchResponse
({
results: {
- B: { frames: [createDataFrameJSON([1, 2, 3])] },
+ C: { frames: [createDataFrameJSON([1, 2, 3])] },
},
});
@@ -239,7 +239,17 @@ describe('AlertingQueryRunner', () => {
hide: true,
},
}),
- createQuery('B'),
+ createQuery('B', {
+ model: {
+ expression: 'A', // depends on A
+ refId: 'B',
+ },
+ }),
+ createQuery('C', {
+ model: {
+ refId: 'C',
+ },
+ }),
],
'B'
);
@@ -248,7 +258,8 @@ describe('AlertingQueryRunner', () => {
const [loading, _data] = values;
expect(loading.A).toBeUndefined();
- expect(loading.B.state).toEqual(LoadingState.Done);
+ expect(loading.B).toBeUndefined();
+ expect(loading.C.state).toEqual(LoadingState.Done);
});
});
});
diff --git a/public/app/features/alerting/unified/state/AlertingQueryRunner.ts b/public/app/features/alerting/unified/state/AlertingQueryRunner.ts
index c3f56b9b2d2..fcba1587acc 100644
--- a/public/app/features/alerting/unified/state/AlertingQueryRunner.ts
+++ b/public/app/features/alerting/unified/state/AlertingQueryRunner.ts
@@ -21,6 +21,7 @@ import { cancelNetworkRequestsOnUnsubscribe } from 'app/features/query/state/pro
import { setStructureRevision } from 'app/features/query/state/processing/revision';
import { AlertQuery } from 'app/types/unified-alerting-dto';
+import { createDagFromQueries, getDescendants } from '../components/rule-editor/dag';
import { getTimeRangeForExpression } from '../utils/timeRange';
export interface AlertingQueryResult {
@@ -51,29 +52,7 @@ export class AlertingQueryRunner {
async run(queries: AlertQuery[], condition: string) {
const empty = initialState(queries, LoadingState.Done);
- const queriesToExclude: string[] = [];
-
- // do not execute if one more of the queries are not runnable,
- // for example not completely configured
- for (const query of queries) {
- const refId = query.model.refId;
-
- if (isExpressionQuery(query.model)) {
- continue;
- }
-
- const dataSourceInstance = await this.dataSourceSrv.get(query.datasourceUid);
- const skipRunningQuery =
- dataSourceInstance instanceof DataSourceWithBackend &&
- dataSourceInstance.filterQuery &&
- !dataSourceInstance.filterQuery(query.model);
-
- if (skipRunningQuery) {
- queriesToExclude.push(refId);
- }
- }
-
- const queriesToRun = reject(queries, (q) => queriesToExclude.includes(q.model.refId));
+ const queriesToRun = await this.prepareQueries(queries);
if (queriesToRun.length === 0) {
return this.subject.next(empty);
@@ -98,6 +77,38 @@ export class AlertingQueryRunner {
});
}
+ // this function will omit any invalid queries and all of its descendants from the list of queries
+ // to do this we will convert the list of queries into a DAG and walk the invalid node's output edges recursively
+ async prepareQueries(queries: AlertQuery[]) {
+ const queriesToExclude: string[] = [];
+
+ // convert our list of queries to a graph
+ const queriesGraph = createDagFromQueries(queries);
+
+ // find all invalid nodes and omit those and their child nodes from the final queries array
+ // ⚠️ also make sure all dependent nodes are omitted, otherwise we will be evaluating a broken graph with missing references
+ for (const query of queries) {
+ const refId = query.model.refId;
+
+ if (isExpressionQuery(query.model)) {
+ continue;
+ }
+
+ const dataSourceInstance = await this.dataSourceSrv.get(query.datasourceUid);
+ const skipRunningQuery =
+ dataSourceInstance instanceof DataSourceWithBackend &&
+ dataSourceInstance.filterQuery &&
+ !dataSourceInstance.filterQuery(query.model);
+
+ if (skipRunningQuery) {
+ const descendants = getDescendants(refId, queriesGraph);
+ queriesToExclude.push(refId, ...descendants);
+ }
+ }
+
+ return reject(queries, (q) => queriesToExclude.includes(q.model.refId));
+ }
+
cancel() {
if (!this.subscription) {
return;
diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx
index 7e8e3d4135c..12b31ff4974 100644
--- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx
+++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx
@@ -60,6 +60,9 @@ const BrowseDashboardsPage = memo(() => {
if (!isSearching && searchState.result) {
stateManager.setState({ result: undefined, includePanels: undefined });
}
+ if (isSearching && searchState.result?.totalRows === 0) {
+ reportInteraction('grafana_empty_state_shown', { source: 'browse_dashboards' });
+ }
}, [isSearching, searchState.result, stateManager]);
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
@@ -114,7 +117,6 @@ const BrowseDashboardsPage = memo(() => {
origin: window.location.pathname === getConfig().appSubUrl + '/dashboards' ? 'Dashboards' : 'Folder view',
});
};
-
return (
{
+ showEmptyState && reportInteraction('grafana_empty_state_shown', { source: 'command_palette' });
+ }, [showEmptyState]);
return showEmptyState ? (
({
newDashboardWithFiltersAndGroupBy: false,
},
bootData: {
+ ...jest.requireActual('@grafana/runtime').config.bootData,
user: {
timezone: 'Africa/Abidjan',
},
diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx
index 91dc7f35ec7..92e5204d742 100644
--- a/public/app/features/explore/Explore.test.tsx
+++ b/public/app/features/explore/Explore.test.tsx
@@ -180,7 +180,7 @@ describe('Explore', () => {
});
it('should render toolbar extension point if extensions is available', async () => {
- usePluginLinksMock.mockReturnValueOnce({
+ usePluginLinksMock.mockReturnValue({
links: [
{
id: '1',
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
index 9345d58b2c1..2cfd2cb39ce 100644
--- a/public/app/features/explore/ExploreToolbar.tsx
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -255,6 +255,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
hideTextValue={showSmallDataSourcePicker}
width={showSmallDataSourcePicker ? 8 : undefined}
/>,
+ ,
].filter(Boolean)}
forceShowLeftItems
>
@@ -295,7 +301,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
),
- ,
+ ,
!isLive && (
{children}, {});
}
+function setupToolbarExtensionPoint(
+ { noTimezone, showQuerylessApps }: { noTimezone?: boolean; showQuerylessApps?: boolean } = { noTimezone: false }
+) {
+ return (
+
+ );
+}
+
describe('ToolbarExtensionPoint', () => {
describe('with extension points', () => {
beforeAll(() => {
@@ -79,13 +91,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
@@ -95,7 +107,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should call onClick from extension when menu item is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' }));
@@ -109,7 +121,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render confirm navigation modal when extension with path is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'ML: Forecast' }));
@@ -123,7 +135,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
- renderWithExploreStore(, {
+ renderWithExploreStore(setupToolbarExtensionPoint(), {
targets,
data,
});
@@ -148,7 +160,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
- renderWithExploreStore(, {
+ renderWithExploreStore(setupToolbarExtensionPoint({ noTimezone: true }), {
targets,
data,
});
@@ -160,7 +172,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should correct extension point id when fetching extensions', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
const [options] = usePluginLinksMock.mock.calls[0];
const { extensionPointId } = options;
@@ -195,13 +207,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
@@ -219,7 +231,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "add to dashboard" action button if one pane is visible', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
await waitFor(() => {
const button = screen.getByRole('button', { name: /add to dashboard/i });
@@ -237,9 +249,119 @@ describe('ToolbarExtensionPoint', () => {
});
it('should not render "add to dashboard" action button', async () => {
- renderWithExploreStore();
+ renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.queryByRole('button', { name: /add to dashboard/i })).not.toBeInTheDocument();
});
});
+
+ describe('with multiple queryless apps links', () => {
+ beforeAll(() => {
+ usePluginLinksMock.mockReturnValue({
+ links: [
+ {
+ pluginId: 'grafana',
+ id: '1',
+ type: PluginExtensionTypes.link,
+ title: 'Add to dashboard',
+ category: 'Dashboards',
+ description: 'Add the current query as a panel to a dashboard',
+ onClick: jest.fn(),
+ },
+ {
+ pluginId: 'grafana-ml-app',
+ id: '2',
+ type: PluginExtensionTypes.link,
+ title: 'ML: Forecast',
+ description: 'Add the query as a ML forecast',
+ path: '/a/grafana-ml-ap/forecast',
+ },
+ {
+ pluginId: 'grafana-pyroscope-app',
+ id: '3',
+ type: PluginExtensionTypes.link,
+ title: 'Explore Profiles',
+ description: 'Explore Profiles',
+ path: '/a/grafana-pyroscope-app',
+ },
+ {
+ pluginId: 'grafana-lokiexplore-app',
+ id: '4',
+ type: PluginExtensionTypes.link,
+ title: 'Explore Logs',
+ description: 'Explore Logs',
+ path: '/a/grafana-lokiexplore-app',
+ },
+ ],
+ isLoading: false,
+ });
+ });
+
+ it('should render menu with extensions without queryless apps when "Add" is clicked', async () => {
+ renderWithExploreStore(setupToolbarExtensionPoint());
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add' }));
+
+ expect(screen.getByRole('group', { name: 'Dashboards' })).toBeVisible();
+ expect(screen.getByRole('menuitem', { name: 'Add to dashboard' })).toBeVisible();
+ expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible();
+ expect(screen.queryByRole('menuitem', { name: 'Explore Profiles' })).not.toBeInTheDocument();
+ });
+
+ it('should render queryless apps links', async () => {
+ renderWithExploreStore(setupToolbarExtensionPoint({ showQuerylessApps: true }));
+
+ await userEvent.click(screen.getByRole('button', { name: /go queryless/i }));
+
+ expect(screen.queryByRole('group', { name: 'Dashboards' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Add to dashboard' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'ML: Forecast' })).not.toBeInTheDocument();
+ expect(screen.getByRole('menuitem', { name: 'Explore Profiles' })).toBeVisible();
+ expect(screen.getByRole('menuitem', { name: 'Explore Logs' })).toBeVisible();
+ });
+ });
+
+ describe('with single queryless apps link', () => {
+ beforeAll(() => {
+ usePluginLinksMock.mockReturnValue({
+ links: [
+ {
+ pluginId: 'grafana',
+ id: '1',
+ type: PluginExtensionTypes.link,
+ title: 'Add to dashboard',
+ category: 'Dashboards',
+ description: 'Add the current query as a panel to a dashboard',
+ onClick: jest.fn(),
+ },
+ {
+ pluginId: 'grafana-ml-app',
+ id: '2',
+ type: PluginExtensionTypes.link,
+ title: 'ML: Forecast',
+ description: 'Add the query as a ML forecast',
+ path: '/a/grafana-ml-ap/forecast',
+ },
+ {
+ pluginId: 'grafana-pyroscope-app',
+ id: '3',
+ type: PluginExtensionTypes.link,
+ title: 'Explore Profiles',
+ description: 'Explore Profiles',
+ path: '/a/grafana-pyroscope-app',
+ },
+ ],
+ isLoading: false,
+ });
+ });
+
+ it('should render single queryless app link', async () => {
+ renderWithExploreStore(setupToolbarExtensionPoint({ showQuerylessApps: true }));
+
+ await userEvent.click(screen.getByRole('button', { name: /go queryless/i }));
+
+ expect(screen.queryByRole('menuitem', { name: 'Explore Profiles' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Explore Logs' })).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
index 399ba36ee9a..35d027b2076 100644
--- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
+++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
@@ -1,66 +1,69 @@
-import { lazy, ReactElement, Suspense, useMemo, useState } from 'react';
+import { ReactElement, useMemo, useState } from 'react';
import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data';
-import { config, usePluginLinks } from '@grafana/runtime';
+import { config, reportInteraction, usePluginLinks } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
-import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, ExplorePanelData, useSelector } from 'app/types';
import { getExploreItemSelector, isLeftPaneSelector, selectCorrelationDetails } from '../state/selectors';
import { ConfirmNavigationModal } from './ConfirmNavigationModal';
-import { ToolbarExtensionPointMenu } from './ToolbarExtensionPointMenu';
-
-const AddToDashboard = lazy(() =>
- import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
-);
+import { BasicExtensions } from './toolbar/BasicExtensions';
+import { QuerylessAppsExtensions } from './toolbar/QuerylessAppsExtensions';
type Props = {
exploreId: string;
timeZone: TimeZone;
+ extensionsToShow: 'queryless' | 'basic';
};
+const QUERYLESS_APPS = ['grafana-pyroscope-app', 'grafana-lokiexplore-app', 'grafana-exploretraces-app'];
+
export function ToolbarExtensionPoint(props: Props): ReactElement | null {
- const { exploreId } = props;
+ const { exploreId, extensionsToShow } = props;
const [selectedExtension, setSelectedExtension] = useState();
const [isOpen, setIsOpen] = useState(false);
const context = useExtensionPointContext(props);
+ // TODO: Pull it up to avoid calling it twice
const { links } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: context,
limitPerPlugin: 3,
});
const selectExploreItem = getExploreItemSelector(exploreId);
- const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length;
+ const noQueriesInPane = Boolean(useSelector(selectExploreItem)?.queries?.length);
- // If we only have the explore core extension point registered we show the old way of
- // adding a query to a dashboard.
- if (links.length <= 1) {
- const canAddPanelToDashboard =
- contextSrv.hasPermission(AccessControlAction.DashboardsCreate) ||
- contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
-
- if (!canAddPanelToDashboard) {
- return null;
- }
-
- return (
-
-
-
- );
- }
-
- const menu = ;
+ const querylessLinks = links.filter((link) => QUERYLESS_APPS.includes(link.pluginId));
+ const commonLinks = links.filter((link) => !QUERYLESS_APPS.includes(link.pluginId));
return (
<>
-
-
- Add
-
-
+ {extensionsToShow === 'queryless' && (
+ {
+ setSelectedExtension(extension);
+ reportInteraction('grafana_explore_queryless_app_link_clicked', {
+ pluginId: extension.pluginId,
+ });
+ }}
+ setIsModalOpen={setIsOpen}
+ isModalOpen={isOpen}
+ />
+ )}
+ {extensionsToShow === 'basic' && (
+
+ )}
{!!selectedExtension && !!selectedExtension.path && (
+ import('./../AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
+);
+
+export function BasicExtensions(props: ExtensionDropdownProps) {
+ const { exploreId, links, setSelectedExtension, setIsModalOpen, isModalOpen, noQueriesInPane } = props;
+ // If we only have the explore core extension point registered we show the old way of
+ // adding a query to a dashboard.
+ if (links.length <= 1) {
+ const canAddPanelToDashboard =
+ contextSrv.hasPermission(AccessControlAction.DashboardsCreate) ||
+ contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
+
+ if (!canAddPanelToDashboard) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ const menu = ;
+
+ return (
+ <>
+
+
+ Add
+
+
+ >
+ );
+}
diff --git a/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx b/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
new file mode 100644
index 00000000000..7433c74393c
--- /dev/null
+++ b/public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
@@ -0,0 +1,42 @@
+import { first } from 'lodash';
+
+import { Dropdown, ToolbarButton } from '@grafana/ui';
+
+import { Trans } from '../../../../core/internationalization';
+import { ToolbarExtensionPointMenu } from '../ToolbarExtensionPointMenu';
+
+import { ExtensionDropdownProps } from './types';
+
+export function QuerylessAppsExtensions(props: ExtensionDropdownProps) {
+ const { links, setSelectedExtension, setIsModalOpen, isModalOpen, noQueriesInPane } = props;
+
+ if (links.length === 0) {
+ return undefined;
+ }
+
+ const menu = ;
+
+ if (links.length === 1) {
+ const link = first(links)!;
+ return (
+ setSelectedExtension(link)}>
+ Go queryless
+
+ );
+ }
+
+ return (
+ <>
+
+
+ Go queryless
+
+
+ >
+ );
+}
diff --git a/public/app/features/explore/extensions/toolbar/types.ts b/public/app/features/explore/extensions/toolbar/types.ts
new file mode 100644
index 00000000000..a23b83b51bd
--- /dev/null
+++ b/public/app/features/explore/extensions/toolbar/types.ts
@@ -0,0 +1,10 @@
+import { PluginExtensionLink } from '@grafana/data';
+
+export type ExtensionDropdownProps = {
+ links: PluginExtensionLink[];
+ exploreId: string;
+ setSelectedExtension: (extension: PluginExtensionLink) => void;
+ setIsModalOpen: (value: boolean) => void;
+ isModalOpen: boolean;
+ noQueriesInPane: boolean;
+};
diff --git a/public/app/features/explore/hooks/useStateSync/internal.utils.ts b/public/app/features/explore/hooks/useStateSync/internal.utils.ts
index e77445939a8..1a12484d533 100644
--- a/public/app/features/explore/hooks/useStateSync/internal.utils.ts
+++ b/public/app/features/explore/hooks/useStateSync/internal.utils.ts
@@ -3,10 +3,10 @@ import { isEqual } from 'lodash';
import { CoreApp, DataSourceApi, ExploreUrlState, isTruthy } from '@grafana/data';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { getLastUsedDatasourceUID } from 'app/core/utils/explore';
-import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
+import { DEFAULT_RANGE } from '../../state/constants';
import { isFulfilled } from '../utils';
export type InitState = 'pending' | 'done' | 'notstarted';
diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts b/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts
index bde44b7c2e3..84544301aa3 100644
--- a/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts
+++ b/public/app/features/explore/hooks/useStateSync/migrators/v0.test.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
+import { DEFAULT_RANGE } from 'app/features/explore/state/constants';
import { v0Migrator } from './v0';
diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v0.ts b/public/app/features/explore/hooks/useStateSync/migrators/v0.ts
index a2948163f00..0fdf4b9f548 100644
--- a/public/app/features/explore/hooks/useStateSync/migrators/v0.ts
+++ b/public/app/features/explore/hooks/useStateSync/migrators/v0.ts
@@ -1,5 +1,5 @@
import { ExploreUrlState } from '@grafana/data';
-import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
+import { DEFAULT_RANGE } from 'app/features/explore/state/constants';
import { BaseExploreURL, MigrationHandler } from './types';
diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts b/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts
index f436ecc5f00..810d8800d65 100644
--- a/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts
+++ b/public/app/features/explore/hooks/useStateSync/migrators/v1.test.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
+import { DEFAULT_RANGE } from 'app/features/explore/state/constants';
import { v1Migrator } from './v1';
diff --git a/public/app/features/explore/hooks/useStateSync/migrators/v1.ts b/public/app/features/explore/hooks/useStateSync/migrators/v1.ts
index 57b23ce4076..58fe595e49a 100644
--- a/public/app/features/explore/hooks/useStateSync/migrators/v1.ts
+++ b/public/app/features/explore/hooks/useStateSync/migrators/v1.ts
@@ -1,6 +1,6 @@
import { ExploreUrlState } from '@grafana/data';
import { ID_ALPHABET, generateExploreId } from 'app/core/utils/explore';
-import { DEFAULT_RANGE } from 'app/features/explore/state/utils';
+import { DEFAULT_RANGE } from 'app/features/explore/state/constants';
import { hasKey } from '../../utils';
diff --git a/public/app/features/explore/state/constants.ts b/public/app/features/explore/state/constants.ts
new file mode 100644
index 00000000000..a4c20f15e0b
--- /dev/null
+++ b/public/app/features/explore/state/constants.ts
@@ -0,0 +1,6 @@
+import { config } from '@grafana/runtime';
+
+export const DEFAULT_RANGE = {
+ from: `now-${config.exploreDefaultTimeOffset}`,
+ to: 'now',
+};
diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts
index 3e1375434c9..ed3e789cecc 100644
--- a/public/app/features/explore/state/main.ts
+++ b/public/app/features/explore/state/main.ts
@@ -14,8 +14,9 @@ import { RichHistorySearchFilters, RichHistorySettings } from '../../../core/uti
import { createAsyncThunk, ThunkResult } from '../../../types';
import { withUniqueRefIds } from '../utils/queries';
+import { DEFAULT_RANGE } from './constants';
import { initializeExplore, InitializeExploreOptions, paneReducer } from './explorePane';
-import { DEFAULT_RANGE, makeExplorePaneState } from './utils';
+import { makeExplorePaneState } from './utils';
//
// Actions and Payloads
@@ -157,7 +158,7 @@ export const navigateToExplore = (
/**
* Global Explore state that handles multiple Explore areas and the split state
*/
-const initialExploreItemState = makeExplorePaneState();
+const initialExploreItemState = () => makeExplorePaneState();
export const initialExploreState: ExploreState = {
syncedTimes: false,
panes: {},
@@ -265,7 +266,7 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
...state,
panes: {
...state.panes,
- [action.meta.arg.exploreId]: initialExploreItemState,
+ [action.meta.arg.exploreId]: initialExploreItemState(),
},
};
}
@@ -274,7 +275,7 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
const initialPanes = Object.entries(state.panes);
const before = initialPanes.slice(0, action.meta.arg.position);
const after = initialPanes.slice(before.length);
- const panes = [...before, [action.meta.arg.exploreId, initialExploreItemState] as const, ...after].reduce(
+ const panes = [...before, [action.meta.arg.exploreId, initialExploreItemState()] as const, ...after].reduce(
(acc, [id, pane]) => ({ ...acc, [id]: pane }),
{}
);
diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts
index 8b2c6888c3f..45b8b39e0e4 100644
--- a/public/app/features/explore/state/utils.ts
+++ b/public/app/features/explore/state/utils.ts
@@ -20,7 +20,7 @@ import {
URLRange,
URLRangeValue,
} from '@grafana/data';
-import { config, getDataSourceSrv } from '@grafana/runtime';
+import { getDataSourceSrv } from '@grafana/runtime';
import { DataQuery, DataSourceJsonData, DataSourceRef, TimeZone } from '@grafana/schema';
import { getLocalRichHistoryStorage } from 'app/core/history/richHistoryStorageProvider';
import { SortOrder } from 'app/core/utils/richHistoryTypes';
@@ -33,12 +33,9 @@ import { setLastUsedDatasourceUID } from '../../../core/utils/explore';
import { getDatasourceSrv } from '../../plugins/datasource_srv';
import { loadSupplementaryQueries } from '../utils/supplementaryQueries';
-export const MAX_HISTORY_AUTOCOMPLETE_ITEMS = 100;
+import { DEFAULT_RANGE } from './constants';
-export const DEFAULT_RANGE = {
- from: `now-${config.exploreDefaultTimeOffset}`,
- to: 'now',
-};
+export const MAX_HISTORY_AUTOCOMPLETE_ITEMS = 100;
const GRAPH_STYLE_KEY = 'grafana.explore.style.graph';
export const storeGraphStyle = (graphStyle: string): void => {
diff --git a/public/app/features/panel/state/getAllSuggestions.test.ts b/public/app/features/panel/state/getAllSuggestions.test.ts
index 17b236ed1ec..552c80a74b5 100644
--- a/public/app/features/panel/state/getAllSuggestions.test.ts
+++ b/public/app/features/panel/state/getAllSuggestions.test.ts
@@ -13,9 +13,6 @@ import { SuggestionName } from 'app/types/suggestions';
import { getAllSuggestions, panelsToCheckFirst } from './getAllSuggestions';
-jest.unmock('app/core/core');
-jest.unmock('app/features/plugins/plugin_loader');
-
for (const pluginId of panelsToCheckFirst) {
config.panels[pluginId] = {
module: `core:plugin/${pluginId}`,
diff --git a/public/app/features/plugins/tests/plugin_loader.test.ts b/public/app/features/plugins/tests/plugin_loader.test.ts
index d41caa1dbe1..9f6c2e3739b 100644
--- a/public/app/features/plugins/tests/plugin_loader.test.ts
+++ b/public/app/features/plugins/tests/plugin_loader.test.ts
@@ -1,6 +1,3 @@
-// Use the real plugin_loader (stubbed by default)
-jest.unmock('app/features/plugins/plugin_loader');
-
jest.mock('app/core/core', () => {
return {
coreModule: {
diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx
index f3a83495df5..f2d5c4674a9 100644
--- a/public/app/features/teams/TeamPages.test.tsx
+++ b/public/app/features/teams/TeamPages.test.tsx
@@ -30,7 +30,6 @@ jest.mock('@grafana/runtime', () => ({
licenseUrl: '',
},
featureToggles: { accesscontrol: true },
- bootData: { navTree: [], user: {} },
buildInfo: {
edition: 'Open Source',
version: '7.5.0',
diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx
index de8e1c3133d..d08b30d1f5f 100644
--- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx
+++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx
@@ -86,7 +86,10 @@ export const GroupByField = (props: Props) => {
.map((t) => ({ label: t, value: t }));
return (
-
+
<>
{query.groupBy?.map((f, i) => {
const tags = tagOptions(f)
@@ -152,15 +155,27 @@ export const GroupByField = (props: Props) => {
);
})}
{query.groupBy && query.groupBy.length > 0 && query.groupBy[0].tag && (
-
+
+ The aggregate by feature is deprecated. We recommend using Explore Traces instead. If you want to write your
+ own TraceQL queries to replicate this API, please check
+
+ this page
+
+ .
+
)}
>
);
};
-export const notice = 'The aggregate by feature is deprecated and will be removed in the future.';
-
const getStyles = (theme: GrafanaTheme2) => ({
addTag: css({
marginLeft: theme.spacing(1),
@@ -169,4 +184,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: '500px',
marginTop: theme.spacing(0.75),
}),
+ noticeLink: css({
+ color: theme.colors.text.link,
+ textDecoration: 'underline',
+ marginLeft: '5px',
+ }),
});
diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json
index 0f53f4ac740..8870ce3246e 100644
--- a/public/locales/de-DE/grafana.json
+++ b/public/locales/de-DE/grafana.json
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
+ },
+ "unknown-table": {
+ "loading": "",
+ "no-unknowns": "",
+ "renamed-or-missing-variables": "",
+ "variable": ""
}
}
}
\ No newline at end of file
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index f430b69d65d..f747547a8a0 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -1261,6 +1261,8 @@
"title-with-name": "Table - {{name}}"
},
"toolbar": {
+ "add-to-extensions": "Add",
+ "add-to-queryless-extensions": "Go queryless",
"aria-label": "Explore toolbar",
"copy-link": "Copy URL",
"copy-link-abs-time": "Copy absolute URL",
diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json
index e8974dc2e7e..dde7f5aff67 100644
--- a/public/locales/es-ES/grafana.json
+++ b/public/locales/es-ES/grafana.json
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
+ },
+ "unknown-table": {
+ "loading": "",
+ "no-unknowns": "",
+ "renamed-or-missing-variables": "",
+ "variable": ""
}
}
}
\ No newline at end of file
diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json
index fc0e768ddad..fd789dec216 100644
--- a/public/locales/fr-FR/grafana.json
+++ b/public/locales/fr-FR/grafana.json
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
+ },
+ "unknown-table": {
+ "loading": "",
+ "no-unknowns": "",
+ "renamed-or-missing-variables": "",
+ "variable": ""
}
}
}
\ No newline at end of file
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index fe6defcf56c..e73d0cf088e 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -1261,6 +1261,8 @@
"title-with-name": "Ŧäþľę - {{name}}"
},
"toolbar": {
+ "add-to-extensions": "Åđđ",
+ "add-to-queryless-extensions": "Ğő qūęřyľęşş",
"aria-label": "Ēχpľőřę ŧőőľþäř",
"copy-link": "Cőpy ŮŖĿ",
"copy-link-abs-time": "Cőpy äþşőľūŧę ŮŖĿ",
diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json
index a1a24d462ee..2985b911ce2 100644
--- a/public/locales/pt-BR/grafana.json
+++ b/public/locales/pt-BR/grafana.json
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
+ },
+ "unknown-table": {
+ "loading": "",
+ "no-unknowns": "",
+ "renamed-or-missing-variables": "",
+ "variable": ""
}
}
}
\ No newline at end of file
diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json
index 7c003199924..2c9db3e0ec4 100644
--- a/public/locales/zh-Hans/grafana.json
+++ b/public/locales/zh-Hans/grafana.json
@@ -3322,6 +3322,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
+ },
+ "unknown-table": {
+ "loading": "",
+ "no-unknowns": "",
+ "renamed-or-missing-variables": "",
+ "variable": ""
}
}
}
\ No newline at end of file
diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts
index f625756bc8b..9511a3e6a97 100644
--- a/public/test/jest-setup.ts
+++ b/public/test/jest-setup.ts
@@ -78,7 +78,6 @@ jest.mock('../app/core/core', () => ({
appEvents: testAppEvents,
}));
jest.mock('../app/angular/partials', () => ({}));
-jest.mock('../app/features/plugins/plugin_loader', () => ({}));
const throwUnhandledRejections = () => {
process.on('unhandledRejection', (err) => {