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

This commit is contained in:
Ryan McKinley committed 2024-12-11 19:35:08 +03:00
commit 2d4c8b1ac1
68 files changed
+1009 -202

No files matched your search

-3
View File
@@ -3160,9 +3160,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/explore/extensions/ToolbarExtensionPoint.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/explore/hooks/useStateSync/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./external.utils\`)", "0"]
],
@@ -1769,7 +1769,7 @@ Configures the batch size for the annotation clean-up job. This setting is used
### tags_length
Enforces the maximum allowed length of the tags for any newly introduced annotations. It can be between 500 and 4096 (inclusive). Default value is 500. Setting it to a higher value would impact performance therefore is not recommended.
Enforces the maximum allowed amount of tags for any newly introduced annotations. This value can be between 500 and 4096 (inclusive). The default value is 500. Setting it to a higher value would impact performance and is therefore not recommended.
## [annotations.dashboard]
@@ -1,19 +1,6 @@
import * as e2e from '@grafana/e2e-selectors';
import { expect, test } from '@grafana/plugin-e2e';
test('should evaluate to false if entire request returns 500', async ({ page, alertRuleEditPage, selectors }) => {
await alertRuleEditPage.alertRuleNameField.fill('Test Alert Rule');
// remove the default query
const queryA = alertRuleEditPage.getAlertRuleQueryRow('A');
await alertRuleEditPage
.getByGrafanaSelector(selectors.components.QueryEditorRow.actionButton('Remove query'), {
root: queryA.locator,
})
.click();
await expect(alertRuleEditPage.evaluate()).not.toBeOK();
});
test('should evaluate to false if entire request returns 200 but partial query result is invalid', async ({
page,
alertRuleEditPage,
@@ -97,7 +97,7 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th
width: 57,
},
menuTabs: {
height: 42,
height: 5,
},
textHighlight: {
text: colors.warning.contrastText,
@@ -119,5 +119,8 @@ function createDebug(): GrafanaTheme2 {
shape: {
borderRadius: 8,
},
spacing: {
gridSize: 10,
},
});
}
@@ -1,10 +1,12 @@
import { css } from '@emotion/css';
import { useCallback, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, InputGroup } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Button, InlineField, InlineFieldRow } from '@grafana/ui';
import { Button, ComponentSize, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { Combobox, ComboboxOption } from '@grafana/ui/src/components/Combobox/Combobox';
import { getPropertiesForButtonSize } from '@grafana/ui/src/components/Forms/commonStyles';
import { PrometheusDatasource } from '../../datasource';
import { regexifyLabelValuesQueryString } from '../parsingUtils';
@@ -25,6 +27,9 @@ export interface MetricComboboxProps {
variableEditor?: boolean;
}
const INLINE_FIELD_WIDTH = 20;
const BUTTON_SIZE: ComponentSize = 'md';
export function MetricCombobox({
datasource,
query,
@@ -86,6 +91,8 @@ export function MetricCombobox({
const metricsExplorerEnabled = config.featureToggles.prometheusMetricEncyclopedia;
const styles = useStyles2(getMectricComboboxStyles);
const asyncSelect = () => {
return (
<InputGroup>
@@ -101,6 +108,7 @@ export function MetricCombobox({
{metricsExplorerEnabled ? (
<Button
size={BUTTON_SIZE}
tooltip="Open metrics explorer"
aria-label="Open metrics explorer"
variant="secondary"
@@ -130,19 +138,23 @@ export function MetricCombobox({
/>
)}
{variableEditor ? (
<InlineFieldRow>
<InlineField
label="Metric"
labelWidth={20}
tooltip={<div>Optional: returns a list of label values for the label name in the specified metric.</div>}
>
{asyncSelect()}
</InlineField>
</InlineFieldRow>
<span className={styles.adaptToParentVariableEditor}>
<InlineFieldRow>
<InlineField
label="Metric"
labelWidth={INLINE_FIELD_WIDTH}
tooltip={<div>Optional: returns a list of label values for the label name in the specified metric.</div>}
>
{asyncSelect()}
</InlineField>
</InlineFieldRow>
</span>
) : (
<EditorFieldGroup>
<EditorField label="Metric">{asyncSelect()}</EditorField>
</EditorFieldGroup>
<span className={styles.adaptToParentQueryEditor}>
<EditorFieldGroup>
<EditorField label="Metric">{asyncSelect()}</EditorField>
</EditorFieldGroup>
</span>
)}
</>
);
@@ -171,3 +183,28 @@ const formatKeyValueStringsForLabelValuesQuery = (query: string, labelsFilters?:
return formatPrometheusLabelFiltersToString(queryString, labelsFilters);
};
const getMectricComboboxStyles = (theme: GrafanaTheme2) => {
const { height } = getPropertiesForButtonSize(BUTTON_SIZE, theme);
const buttonSpace = parseInt(theme.spacing(height), 10);
const inlineFieldSpace = parseInt(theme.spacing(INLINE_FIELD_WIDTH), 10);
const widthToSubstract = inlineFieldSpace + buttonSpace;
return {
adaptToParentQueryEditor: css({
// Take metrics explorer button into account
maxWidth: `calc(100% - ${buttonSpace}px)`,
}),
adaptToParentVariableEditor: css({
maxWidth: '100%',
display: 'flex',
'[class*="InlineFieldRow"]': {
'> div': {
'label + div': {
// Take label and the metrics explorer button into account
maxWidth: `calc(100% - ${widthToSubstract}px)`,
},
},
},
}),
};
};
@@ -6,7 +6,7 @@ import { DataSourceApi, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorRow } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Drawer } from '@grafana/ui';
import { Drawer, useStyles2 } from '@grafana/ui';
import { PrometheusDatasource } from '../../datasource';
import promqlGrammar from '../../promql';
@@ -58,6 +58,7 @@ export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
checkLlms();
}
}, [prometheusPromQAIL]);
const styles = useStyles2(getPromQueryBuilderStyles);
return (
<>
@@ -71,9 +72,12 @@ export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
/>
</Drawer>
)}
<EditorRow>
<MetricsLabelsSection query={query} onChange={onChange} datasource={datasource} />
</EditorRow>
<span className={styles.addaptToParent}>
<EditorRow>
<MetricsLabelsSection query={query} onChange={onChange} datasource={datasource} />
</EditorRow>
</span>
{initHints.length ? (
<div
className={css({
@@ -150,5 +154,9 @@ export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
</>
);
});
const getPromQueryBuilderStyles = () => ({
addaptToParent: css({
maxWidth: '100%',
}),
});
PromQueryBuilder.displayName = 'PromQueryBuilder';
@@ -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)',
@@ -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`,
@@ -27,6 +27,7 @@ const getStyles = (theme: GrafanaTheme2) => {
flexWrap: 'wrap',
alignContent: 'flex-start',
rowGap: theme.spacing(0.5),
maxWidth: '100%',
}),
};
};
@@ -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,
@@ -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%',
@@ -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)': {
@@ -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 (
<div className={styles.container} data-testid={testId}>
@@ -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',
}),
});
@@ -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
@@ -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
}
@@ -14,6 +14,7 @@ const (
TypeUser string = "user"
TypeServiceAccount string = "service-account"
TypeRenderService string = "render"
TypeAnonymous string = "anonymous"
TypeTeam string = "team"
TypeRole string = "role"
)
@@ -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
+1
View File
@@ -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
+22 -2
View File
@@ -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 == "" {
+45
View File
@@ -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
}
+70
View File
@@ -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
}
+167
View File
@@ -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))
})
}
@@ -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(),
})
}
}
}
@@ -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) {
+17 -1
View File
@@ -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: ") {
+17 -1
View File
@@ -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"
@@ -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',
},
};
});
@@ -118,6 +118,7 @@ const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
display: 'flex',
gap: theme.spacing(1),
height: theme.spacing(theme.components.height.md),
}),
};
};
@@ -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;
-11
View File
@@ -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();
+1 -1
View File
@@ -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';
@@ -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']],
@@ -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
@@ -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();
@@ -216,10 +216,10 @@ describe('AlertingQueryRunner', () => {
});
});
it('should skip hidden queries', async () => {
it('should skip hidden queries and descendant nodes', async () => {
const results = createFetchResponse<AlertingQueryResponse>({
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);
});
});
});
@@ -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;
@@ -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 (
<Page
navId="dashboards/browse"
@@ -122,6 +122,9 @@ const RenderResults = ({ isFetchingSearchResults, searchResults }: RenderResults
}, [kbarResults, dashboardsSectionTitle, dashboardResultItems, foldersSectionTitle, folderResultItems]);
const showEmptyState = !isFetchingSearchResults && items.length === 0;
useEffect(() => {
showEmptyState && reportInteraction('grafana_empty_state_shown', { source: 'command_palette' });
}, [showEmptyState]);
return showEmptyState ? (
<EmptyState
@@ -45,6 +45,7 @@ jest.mock('@grafana/runtime', () => ({
newDashboardWithFiltersAndGroupBy: false,
},
bootData: {
...jest.requireActual('@grafana/runtime').config.bootData,
user: {
timezone: 'Africa/Abidjan',
},
+1 -1
View File
@@ -180,7 +180,7 @@ describe('Explore', () => {
});
it('should render toolbar extension point if extensions is available', async () => {
usePluginLinksMock.mockReturnValueOnce({
usePluginLinksMock.mockReturnValue({
links: [
{
id: '1',
+12 -1
View File
@@ -255,6 +255,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
hideTextValue={showSmallDataSourcePicker}
width={showSmallDataSourcePicker ? 8 : undefined}
/>,
<ToolbarExtensionPoint
key="toolbar-extension-point"
exploreId={exploreId}
timeZone={timeZone}
extensionsToShow="queryless"
/>,
].filter(Boolean)}
forceShowLeftItems
>
@@ -295,7 +301,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
</ToolbarButton>
</ButtonGroup>
),
<ToolbarExtensionPoint key="toolbar-extension-point" exploreId={exploreId} timeZone={timeZone} />,
<ToolbarExtensionPoint
key="toolbar-extension-point"
exploreId={exploreId}
timeZone={timeZone}
extensionsToShow="basic"
/>,
!isLive && (
<ExploreTimeControls
key="timeControls"
@@ -51,6 +51,18 @@ function renderWithExploreStore(
render(<Provider store={store}>{children}</Provider>, {});
}
function setupToolbarExtensionPoint(
{ noTimezone, showQuerylessApps }: { noTimezone?: boolean; showQuerylessApps?: boolean } = { noTimezone: false }
) {
return (
<ToolbarExtensionPoint
exploreId="left"
timeZone={noTimezone ? '' : 'browser'}
extensionsToShow={showQuerylessApps ? 'queryless' : 'basic'}
/>
);
}
describe('ToolbarExtensionPoint', () => {
describe('with extension points', () => {
beforeAll(() => {
@@ -79,13 +91,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />, {
renderWithExploreStore(setupToolbarExtensionPoint(), {
targets,
data,
});
@@ -148,7 +160,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="" />, {
renderWithExploreStore(setupToolbarExtensionPoint({ noTimezone: true }), {
targets,
data,
});
@@ -160,7 +172,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should correct extension point id when fetching extensions', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
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();
});
});
});
@@ -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<PluginExtensionLink | undefined>();
const [isOpen, setIsOpen] = useState<boolean>(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 (
<Suspense fallback={null}>
<AddToDashboard exploreId={exploreId} />
</Suspense>
);
}
const menu = <ToolbarExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
const querylessLinks = links.filter((link) => QUERYLESS_APPS.includes(link.pluginId));
const commonLinks = links.filter((link) => !QUERYLESS_APPS.includes(link.pluginId));
return (
<>
<Dropdown onVisibleChange={setIsOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="Add" disabled={!Boolean(noQueriesInPane)} variant="canvas" isOpen={isOpen}>
Add
</ToolbarButton>
</Dropdown>
{extensionsToShow === 'queryless' && (
<QuerylessAppsExtensions
links={querylessLinks}
noQueriesInPane={noQueriesInPane}
exploreId={exploreId}
setSelectedExtension={(extension) => {
setSelectedExtension(extension);
reportInteraction('grafana_explore_queryless_app_link_clicked', {
pluginId: extension.pluginId,
});
}}
setIsModalOpen={setIsOpen}
isModalOpen={isOpen}
/>
)}
{extensionsToShow === 'basic' && (
<BasicExtensions
links={commonLinks}
noQueriesInPane={noQueriesInPane}
exploreId={exploreId}
setSelectedExtension={setSelectedExtension}
setIsModalOpen={setIsOpen}
isModalOpen={isOpen}
/>
)}
{!!selectedExtension && !!selectedExtension.path && (
<ConfirmNavigationModal
path={selectedExtension.path}
@@ -0,0 +1,47 @@
import { lazy, Suspense } from 'react';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { Trans } from '../../../../core/internationalization';
import { ToolbarExtensionPointMenu } from '../ToolbarExtensionPointMenu';
import { ExtensionDropdownProps } from './types';
const AddToDashboard = lazy(() =>
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 (
<Suspense fallback={null}>
<AddToDashboard exploreId={exploreId} />
</Suspense>
);
}
const menu = <ToolbarExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
return (
<>
<Dropdown onVisibleChange={setIsModalOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="Add" disabled={!Boolean(noQueriesInPane)} variant="canvas" isOpen={isModalOpen}>
<Trans i18nKey="explore.toolbar.add-to-extensions">Add</Trans>
</ToolbarButton>
</Dropdown>
</>
);
}
@@ -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 = <ToolbarExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
if (links.length === 1) {
const link = first(links)!;
return (
<ToolbarButton variant="canvas" icon={link.icon} onClick={() => setSelectedExtension(link)}>
<Trans i18nKey="explore.toolbar.add-to-queryless-extensions">Go queryless</Trans>
</ToolbarButton>
);
}
return (
<>
<Dropdown onVisibleChange={setIsModalOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton
aria-label="Go Queryless"
disabled={!Boolean(noQueriesInPane)}
variant="canvas"
isOpen={isModalOpen}
>
<Trans i18nKey="explore.toolbar.add-to-queryless-extensions">Go queryless</Trans>
</ToolbarButton>
</Dropdown>
</>
);
}
@@ -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;
};
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -0,0 +1,6 @@
import { config } from '@grafana/runtime';
export const DEFAULT_RANGE = {
from: `now-${config.exploreDefaultTimeOffset}`,
to: 'now',
};
+5 -4
View File
@@ -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 }),
{}
);
+3 -6
View File
@@ -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 => {
@@ -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}`,
@@ -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: {
@@ -30,7 +30,6 @@ jest.mock('@grafana/runtime', () => ({
licenseUrl: '',
},
featureToggles: { accesscontrol: true },
bootData: { navTree: [], user: {} },
buildInfo: {
edition: 'Open Source',
version: '7.5.0',
@@ -86,7 +86,10 @@ export const GroupByField = (props: Props) => {
.map((t) => ({ label: t, value: t }));
return (
<InlineSearchField label="Aggregate by" tooltip={`${notice} Select one or more tags to see the metrics summary.`}>
<InlineSearchField
label="Aggregate by"
tooltip={`Note: We recommend using Explore Traces instead. Select one or more tags to see the metrics summary.`}
>
<>
{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 && (
<Alert title={notice} severity="warning" className={styles.notice}></Alert>
<Alert title="" severity="warning" className={styles.notice}>
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
<a
href={
'https://grafana.com/docs/tempo/latest/api_docs/metrics-summary/#deprecation-in-favor-of-traceql-metrics'
}
className={styles.noticeLink}
target="_blank"
rel="noreferrer noopener"
>
this page
</a>
.
</Alert>
)}
</>
</InlineSearchField>
);
};
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',
}),
});
+6
View File
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
},
"unknown-table": {
"loading": "",
"no-unknowns": "",
"renamed-or-missing-variables": "",
"variable": ""
}
}
}
+2
View File
@@ -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",
+6
View File
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
},
"unknown-table": {
"loading": "",
"no-unknowns": "",
"renamed-or-missing-variables": "",
"variable": ""
}
}
}
+6
View File
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
},
"unknown-table": {
"loading": "",
"no-unknowns": "",
"renamed-or-missing-variables": "",
"variable": ""
}
}
}
@@ -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 äþşőľūŧę ŮŖĿ",
+6
View File
@@ -3336,6 +3336,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
},
"unknown-table": {
"loading": "",
"no-unknowns": "",
"renamed-or-missing-variables": "",
"variable": ""
}
}
}
+6
View File
@@ -3322,6 +3322,12 @@
"info-box-content": "",
"info-box-content-2": "",
"title": ""
},
"unknown-table": {
"loading": "",
"no-unknowns": "",
"renamed-or-missing-variables": "",
"variable": ""
}
}
}
-1
View File
@@ -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) => {