Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
2d4c8b1ac1
68 files changed
+1009
-202
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: ") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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',
|
||||
},
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('Explore', () => {
|
||||
});
|
||||
|
||||
it('should render toolbar extension point if extensions is available', async () => {
|
||||
usePluginLinksMock.mockReturnValueOnce({
|
||||
usePluginLinksMock.mockReturnValue({
|
||||
links: [
|
||||
{
|
||||
id: '1',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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 }),
|
||||
{}
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
@@ -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": "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",
|
||||
|
||||
@@ -3336,6 +3336,12 @@
|
||||
"info-box-content": "",
|
||||
"info-box-content-2": "",
|
||||
"title": ""
|
||||
},
|
||||
"unknown-table": {
|
||||
"loading": "",
|
||||
"no-unknowns": "",
|
||||
"renamed-or-missing-variables": "",
|
||||
"variable": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 äþşőľūŧę ŮŖĿ",
|
||||
|
||||
@@ -3336,6 +3336,12 @@
|
||||
"info-box-content": "",
|
||||
"info-box-content-2": "",
|
||||
"title": ""
|
||||
},
|
||||
"unknown-table": {
|
||||
"loading": "",
|
||||
"no-unknowns": "",
|
||||
"renamed-or-missing-variables": "",
|
||||
"variable": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3322,6 +3322,12 @@
|
||||
"info-box-content": "",
|
||||
"info-box-content-2": "",
|
||||
"title": ""
|
||||
},
|
||||
"unknown-table": {
|
||||
"loading": "",
|
||||
"no-unknowns": "",
|
||||
"renamed-or-missing-variables": "",
|
||||
"variable": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user