0 {
+ resourceType := rules[0].ResourceType()
+ provenances, err = service.provenanceStore.GetProvenances(ctx, user.GetOrgID(), resourceType)
+ if err != nil {
+ return nil, nil, "", err
+ }
+ }
+
+ return rules, provenances, nextToken, nil
+}
+
func (service *AlertRuleService) GetAlertRules(ctx context.Context, user identity.Requester) ([]*models.AlertRule, map[string]models.Provenance, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go
index ea488b83971..359a09e2e33 100644
--- a/pkg/services/ngalert/provisioning/alert_rules_test.go
+++ b/pkg/services/ngalert/provisioning/alert_rules_test.go
@@ -1423,6 +1423,99 @@ func TestGetRuleGroup(t *testing.T) {
})
}
+func TestListAlertRules(t *testing.T) {
+ orgID := rand.Int63()
+ u := &user.SignedInUser{OrgID: orgID}
+ groupKey1 := models.GenerateGroupKey(orgID)
+ groupKey2 := models.GenerateGroupKey(orgID)
+ gen := models.RuleGen
+ rules1 := gen.With(gen.WithGroupKey(groupKey1), gen.WithUniqueGroupIndex()).GenerateManyRef(3)
+ models.RulesGroup(rules1).SortByGroupIndex()
+ rules2 := gen.With(gen.WithGroupKey(groupKey2), gen.WithUniqueGroupIndex()).GenerateManyRef(4)
+ models.RulesGroup(rules2).SortByGroupIndex()
+ allRules := append(rules1, rules2...)
+
+ fs := foldertest.NewFakeService()
+ fs.AddFolder(&folder.Folder{
+ OrgID: orgID,
+ UID: groupKey1.NamespaceUID,
+ Title: "folder1",
+ })
+ fs.AddFolder(&folder.Folder{
+ OrgID: orgID,
+ UID: groupKey2.NamespaceUID,
+ Title: "folder2",
+ })
+
+ initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) {
+ service, ruleStore, provenanceStore, ac := initService(t)
+ service.folderService = fs
+ ruleStore.Rules = map[int64][]*models.AlertRule{
+ orgID: allRules,
+ }
+ ac.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) {
+ return true, nil
+ }
+
+ return service, ruleStore, provenanceStore, ac
+ }
+
+ t.Run("when user can read all rules", func(t *testing.T) {
+ t.Run("should skip AuthorizeRuleGroupRead and return all rules", func(t *testing.T) {
+ service, _, _, ac := initServiceWithData(t)
+ ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
+ return true, nil
+ }
+
+ rules, _, token, err := service.ListAlertRules(context.Background(), u, ListAlertRulesOptions{})
+ require.NoError(t, err)
+ // check that rules contain all uids from allRules
+ ruleUIDs := make(map[string]bool)
+ for _, r := range rules {
+ ruleUIDs[r.UID] = true
+ }
+ for _, r := range allRules {
+ assert.True(t, ruleUIDs[r.UID])
+ }
+ require.Len(t, ruleUIDs, len(allRules))
+ require.Empty(t, token)
+
+ assert.Len(t, ac.Calls, 1)
+ assert.Equal(t, "CanReadAllRules", ac.Calls[0].Method)
+ })
+ })
+
+ t.Run("when user cannot read all rules", func(t *testing.T) {
+ t.Run("should return only rules in accessible folders", func(t *testing.T) {
+ service, _, _, ac := initServiceWithData(t)
+ ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
+ return false, nil
+ }
+ ac.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) {
+ return folder.GetNamespaceUID() == groupKey2.NamespaceUID, nil
+ }
+
+ rules, _, token, err := service.ListAlertRules(context.Background(), u, ListAlertRulesOptions{})
+ require.NoError(t, err)
+ // check that rules contain all uids from rules1
+ ruleUIDs := make(map[string]bool)
+ for _, r := range rules {
+ ruleUIDs[r.UID] = true
+ }
+ for _, r := range rules2 {
+ assert.True(t, ruleUIDs[r.UID])
+ }
+ require.Len(t, ruleUIDs, len(rules2))
+ require.Empty(t, token)
+
+ assert.Len(t, ac.Calls, 3)
+ assert.Equal(t, "CanReadAllRules", ac.Calls[0].Method)
+ assert.Equal(t, "HasAccessInFolder", ac.Calls[1].Method)
+ assert.Equal(t, "HasAccessInFolder", ac.Calls[2].Method)
+ })
+ })
+}
+
func TestGetAlertRules(t *testing.T) {
orgID := rand.Int63()
u := &user.SignedInUser{OrgID: orgID}
diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go
index 6b3c2fb00d6..753c874cce3 100644
--- a/pkg/services/ngalert/provisioning/persist.go
+++ b/pkg/services/ngalert/provisioning/persist.go
@@ -32,6 +32,7 @@ type TransactionManager interface {
type RuleStore interface {
GetAlertRuleByUID(ctx context.Context, query *models.GetAlertRuleByUIDQuery) (*models.AlertRule, error)
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error)
+ ListAlertRulesPaginated(ctx context.Context, query *models.ListAlertRulesExtendedQuery) (models.RulesGroup, string, error)
GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error)
InsertAlertRules(ctx context.Context, user *models.UserUID, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error)
UpdateAlertRules(ctx context.Context, user *models.UserUID, rule []models.UpdateRule) error
diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go
index beefd0287fc..059ef542d39 100644
--- a/pkg/services/ngalert/provisioning/testing.go
+++ b/pkg/services/ngalert/provisioning/testing.go
@@ -113,6 +113,7 @@ type fakeRuleAccessControlService struct {
AuthorizeRuleChangesFunc func(ctx context.Context, user identity.Requester, change *store.GroupDelta) error
CanReadAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error)
CanWriteAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error)
+ HasAccessInFolderFunc func(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error)
}
func (s *fakeRuleAccessControlService) RecordCall(method string, args ...interface{}) {
@@ -167,6 +168,14 @@ func (s *fakeRuleAccessControlService) CanWriteAllRules(ctx context.Context, use
return false, nil
}
+func (s *fakeRuleAccessControlService) HasAccessInFolder(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) {
+ s.RecordCall("HasAccessInFolder", ctx, user, folder)
+ if s.HasAccessInFolderFunc != nil {
+ return s.HasAccessInFolderFunc(ctx, user, folder)
+ }
+ return true, nil
+}
+
type fakeAlertRuleNotificationStore struct {
Calls []call
diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go
index 67aba2ab917..e0b3b6faf0d 100644
--- a/pkg/services/ngalert/schedule/schedule.go
+++ b/pkg/services/ngalert/schedule/schedule.go
@@ -18,9 +18,9 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/schedule/ticker"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/setting"
- "github.com/grafana/grafana/pkg/util/ticker"
)
// ScheduleService is an interface for a service that schedules the evaluation
@@ -169,7 +169,7 @@ func NewScheduler(cfg SchedulerCfg, stateManager *state.Manager) *schedule {
func (sch *schedule) Run(ctx context.Context) error {
sch.log.Info("Starting scheduler", "tickInterval", sch.baseInterval, "maxAttempts", sch.maxAttempts)
- t := ticker.New(sch.clock, sch.baseInterval, sch.metrics.Ticker)
+ t := ticker.New(sch.clock, sch.baseInterval, sch.metrics.Ticker, sch.log)
defer t.Stop()
if err := sch.schedulePeriodic(ctx, t); err != nil {
diff --git a/pkg/util/ticker/metrics.go b/pkg/services/ngalert/schedule/ticker/metrics.go
similarity index 100%
rename from pkg/util/ticker/metrics.go
rename to pkg/services/ngalert/schedule/ticker/metrics.go
diff --git a/pkg/util/ticker/ticker.go b/pkg/services/ngalert/schedule/ticker/ticker.go
similarity index 89%
rename from pkg/util/ticker/ticker.go
rename to pkg/services/ngalert/schedule/ticker/ticker.go
index 502325ca07f..a52b9c4e559 100644
--- a/pkg/util/ticker/ticker.go
+++ b/pkg/services/ngalert/schedule/ticker/ticker.go
@@ -21,10 +21,11 @@ type T struct {
interval time.Duration
metrics *Metrics
stopCh chan struct{}
+ logger log.Logger
}
// NewTicker returns a Ticker that ticks on interval marks (or very shortly after) starting at c.Now(), and never drops ticks. interval should not be negative or zero.
-func New(c clock.Clock, interval time.Duration, metric *Metrics) *T {
+func New(c clock.Clock, interval time.Duration, metric *Metrics, logger log.Logger) *T {
if interval <= 0 {
panic(fmt.Errorf("non-positive interval [%v] is not allowed", interval))
}
@@ -35,6 +36,7 @@ func New(c clock.Clock, interval time.Duration, metric *Metrics) *T {
interval: interval,
metrics: metric,
stopCh: make(chan struct{}),
+ logger: logger,
}
metric.IntervalSeconds.Set(t.interval.Seconds()) // Seconds report fractional part as well, so it matches the format of the timestamp we report below
go t.run()
@@ -47,8 +49,7 @@ func getStartTick(clk clock.Clock, interval time.Duration) time.Time {
}
func (t *T) run() {
- logger := log.New("ticker")
- logger.Info("starting", "first_tick", t.last.Add(t.interval))
+ t.logger.Info("starting", "component", "ticker", "first_tick", t.last.Add(t.interval))
LOOP:
for {
next := t.last.Add(t.interval) // calculate the time of the next tick
@@ -72,7 +73,7 @@ LOOP:
break LOOP
}
}
- logger.Info("stopped", "last_tick", t.last)
+ t.logger.Info("stopped", "component", "ticker", "last_tick", t.last)
}
// Stop stops the ticker. It does not close the C channel
diff --git a/pkg/util/ticker/ticker_test.go b/pkg/services/ngalert/schedule/ticker/ticker_test.go
similarity index 95%
rename from pkg/util/ticker/ticker_test.go
rename to pkg/services/ngalert/schedule/ticker/ticker_test.go
index 39cbca221e7..b83dabff083 100644
--- a/pkg/util/ticker/ticker_test.go
+++ b/pkg/services/ngalert/schedule/ticker/ticker_test.go
@@ -13,6 +13,8 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/infra/log/logtest"
)
func TestTicker(t *testing.T) {
@@ -51,7 +53,7 @@ func TestTicker(t *testing.T) {
interval := time.Duration(rand.Int63n(100)+10) * time.Second
clk := clock.NewMock()
clk.Add(interval) // align clock with the start tick
- ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"))
+ ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"), &logtest.Fake{})
ticks := rand.Intn(9) + 1
jitter := rand.Int63n(int64(interval) - 1)
@@ -85,7 +87,7 @@ func TestTicker(t *testing.T) {
t.Run("should not put anything to channel until it's time", func(t *testing.T) {
clk := clock.NewMock()
interval := time.Duration(rand.Int63n(9)+1) * time.Second
- ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"))
+ ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"), &logtest.Fake{})
expectedTick := clk.Now().Add(interval)
for {
require.Empty(t, ticker.C)
@@ -102,7 +104,7 @@ func TestTicker(t *testing.T) {
t.Run("should put the tick in the channel immediately if it is behind", func(t *testing.T) {
clk := clock.NewMock()
interval := time.Duration(rand.Int63n(9)+1) * time.Second
- ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"))
+ ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"), &logtest.Fake{})
// We can expect the first tick to be at a consistent interval. Take a snapshot of the clock now, before we advance it.
expectedTick := clk.Now().Add(interval)
@@ -131,7 +133,7 @@ func TestTicker(t *testing.T) {
clk.Set(time.Now())
interval := time.Duration(rand.Int63n(9)+1) * time.Second
registry := prometheus.NewPedanticRegistry()
- ticker := New(clk, interval, NewMetrics(registry, "test"))
+ ticker := New(clk, interval, NewMetrics(registry, "test"), &logtest.Fake{})
expectedTick := getStartTick(clk, interval).Add(interval)
expectedMetricFmt := `# HELP grafana_test_ticker_interval_seconds Interval at which the ticker is meant to tick.
@@ -174,7 +176,7 @@ func TestTicker(t *testing.T) {
t.Run("when it waits for the next tick", func(t *testing.T) {
clk := clock.NewMock()
interval := time.Duration(rand.Int63n(9)+1) * time.Second
- ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"))
+ ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"), &logtest.Fake{})
clk.Add(interval)
readChanOrFail(t, ticker.C)
ticker.Stop()
@@ -185,7 +187,7 @@ func TestTicker(t *testing.T) {
t.Run("when it waits for the tick to be consumed", func(t *testing.T) {
clk := clock.NewMock()
interval := time.Duration(rand.Int63n(9)+1) * time.Second
- ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"))
+ ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"), &logtest.Fake{})
clk.Add(interval)
ticker.Stop()
require.Empty(t, ticker.C)
@@ -194,7 +196,7 @@ func TestTicker(t *testing.T) {
t.Run("multiple times", func(t *testing.T) {
clk := clock.NewMock()
interval := time.Duration(rand.Int63n(9)+1) * time.Second
- ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"))
+ ticker := New(clk, interval, NewMetrics(prometheus.NewRegistry(), "test"), &logtest.Fake{})
ticker.Stop()
ticker.Stop()
ticker.Stop()
diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go
index ab8676c754d..6a19a31ff05 100644
--- a/pkg/services/ngalert/store/alert_rule.go
+++ b/pkg/services/ngalert/store/alert_rule.go
@@ -2,14 +2,16 @@ package store
import (
"context"
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "math"
+ "slices"
"strings"
"github.com/google/uuid"
"golang.org/x/exp/maps"
- "golang.org/x/exp/slices"
"github.com/grafana/grafana/pkg/util/xorm"
@@ -762,8 +764,23 @@ func shouldIncludeRule(rule *ngmodels.AlertRule, query *ngmodels.ListAlertRulesB
return true
}
-// ListAlertRules is a handler for retrieving alert rules of specific organisation.
func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (result ngmodels.RulesGroup, err error) {
+ result, nextToken, err := st.ListAlertRulesPaginated(ctx, &ngmodels.ListAlertRulesExtendedQuery{
+ ListAlertRulesQuery: *query,
+ ContinueToken: "",
+ Limit: 0,
+ RuleType: ngmodels.RuleTypeFilterAll,
+ })
+ // This should never happen, as Limit is 0, which means no pagination.
+ if nextToken != "" {
+ err = fmt.Errorf("unexpected next token %q, expected empty string", nextToken)
+ st.Logger.Error("ListAlertRules returned a next token, but it should not have, this is a bug!", "next_token", nextToken, "query", query)
+ }
+ return result, err
+}
+
+// ListAlertRulesPaginated is a handler for retrieving alert rules of specific organization paginated.
+func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.ListAlertRulesExtendedQuery) (result ngmodels.RulesGroup, nextToken string, err error) {
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
q := sess.Table("alert_rule")
@@ -819,8 +836,37 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
}
}
+ // FIXME: record is nullable but we don't save it as null when it's nil
+ switch query.RuleType {
+ case ngmodels.RuleTypeFilterAlerting:
+ q = q.Where("record = ''")
+ case ngmodels.RuleTypeFilterRecording:
+ q = q.Where("record != ''")
+ case ngmodels.RuleTypeFilterAll:
+ // no additional filter
+ default:
+ return fmt.Errorf("unknown rule type filter %q", query.RuleType)
+ }
+
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
+ if query.ContinueToken != "" {
+ cursor, err := decodeCursor(query.ContinueToken)
+ if err != nil {
+ return fmt.Errorf("invalid continue token: %w", err)
+ }
+
+ // Build cursor condition that matches the ORDER BY clause
+ q = buildCursorCondition(q, cursor)
+ }
+
+ if query.Limit > 0 {
+ // Ensure we clamp to the max int available on the platform
+ lim := min(query.Limit, math.MaxInt)
+ // Fetch one extra rule to determine if there are more results
+ q = q.Limit(int(lim) + 1)
+ }
+
alertRules := make([]*ngmodels.AlertRule, 0)
rule := new(alertRule)
rows, err := q.Rows(rule)
@@ -833,50 +879,107 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
// Deserialize each rule separately in case any of them contain invalid JSON.
for rows.Next() {
- rule := new(alertRule)
- err = rows.Scan(rule)
- if err != nil {
- st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err)
- continue
+ converted, ok := st.handleRuleRow(rows, query, groupsMap)
+ if ok {
+ alertRules = append(alertRules, converted)
}
- converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
- if err != nil {
- st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRules", "error", err)
- continue
+ }
+
+ genToken := query.Limit > 0 && len(alertRules) > int(query.Limit)
+ if genToken {
+ // Remove the extra item we fetched
+ alertRules = alertRules[:query.Limit]
+
+ // Generate next continue token from the last item
+ lastRule := alertRules[len(alertRules)-1]
+ cursor := continueCursor{
+ NamespaceUID: lastRule.NamespaceUID,
+ RuleGroup: lastRule.RuleGroup,
+ RuleGroupIdx: int64(lastRule.RuleGroupIndex),
+ ID: lastRule.ID,
}
- if query.ReceiverName != "" { // remove false-positive hits from the result
- if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
- return settings.Receiver == query.ReceiverName
- }) {
- continue
- }
- }
- if query.TimeIntervalName != "" {
- if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
- return slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) || slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName)
- }) {
- continue
- }
- }
- if query.HasPrometheusRuleDefinition != nil { // remove false-positive hits from the result
- if *query.HasPrometheusRuleDefinition != converted.HasPrometheusRuleDefinition() {
- continue
- }
- }
- // MySQL (and potentially other databases) can use case-insensitive comparison.
- // This code makes sure we return groups that only exactly match the filter.
- if groupsMap != nil {
- if _, ok := groupsMap[converted.RuleGroup]; !ok {
- continue
- }
- }
- alertRules = append(alertRules, &converted)
+
+ nextToken = encodeCursor(cursor)
}
result = alertRules
return nil
})
- return result, err
+ return result, nextToken, err
+}
+
+func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesExtendedQuery, groupsSet map[string]struct{}) (*ngmodels.AlertRule, bool) {
+ rule := new(alertRule)
+ err := rows.Scan(rule)
+ if err != nil {
+ st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err)
+ return nil, false
+ }
+ converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
+ if err != nil {
+ st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRules", "error", err)
+ return nil, false
+ }
+ if query.ReceiverName != "" { // remove false-positive hits from the result
+ if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
+ return settings.Receiver == query.ReceiverName
+ }) {
+ return nil, false
+ }
+ }
+ if query.TimeIntervalName != "" {
+ if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
+ return slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) || slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName)
+ }) {
+ return nil, false
+ }
+ }
+ if query.HasPrometheusRuleDefinition != nil { // remove false-positive hits from the result
+ if *query.HasPrometheusRuleDefinition != converted.HasPrometheusRuleDefinition() {
+ return nil, false
+ }
+ }
+ // MySQL (and potentially other databases) can use case-insensitive comparison.
+ // This code makes sure we return groups that only exactly match the filter.
+ if groupsSet != nil {
+ if _, ok := groupsSet[converted.RuleGroup]; !ok {
+ return nil, false
+ }
+ }
+ return &converted, true
+}
+
+type continueCursor struct {
+ NamespaceUID string `json:"n"`
+ RuleGroup string `json:"g"`
+ RuleGroupIdx int64 `json:"i"`
+ ID int64 `json:"d"`
+}
+
+func encodeCursor(c continueCursor) string {
+ data, _ := json.Marshal(c)
+ return base64.URLEncoding.EncodeToString(data)
+}
+
+func decodeCursor(token string) (continueCursor, error) {
+ var c continueCursor
+ data, err := base64.URLEncoding.DecodeString(token)
+ if err != nil {
+ return c, fmt.Errorf("failed to decode token: %w", err)
+ }
+
+ if err := json.Unmarshal(data, &c); err != nil {
+ return c, fmt.Errorf("failed to unmarshal cursor: %w", err)
+ }
+
+ return c, nil
+}
+
+func buildCursorCondition(sess *xorm.Session, c continueCursor) *xorm.Session {
+ return sess.Where("(namespace_uid > ?)", c.NamespaceUID).
+ Or("(namespace_uid = ? AND rule_group > ?)", c.NamespaceUID, c.RuleGroup).
+ Or("(namespace_uid = ? AND rule_group = ? AND rule_group_idx > ?)", c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx).
+ Or("(namespace_uid = ? AND rule_group = ? AND rule_group_idx = ? AND id > ?)", c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx, c.ID)
}
// Count returns either the number of the alert rules under a specific org (if orgID is not zero)
diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go
index c3031528013..1e0119fe3fa 100644
--- a/pkg/services/ngalert/store/alert_rule_test.go
+++ b/pkg/services/ngalert/store/alert_rule_test.go
@@ -1956,6 +1956,146 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
+func TestIntegration_ListAlertRulesPaginated(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+ sqlStore := db.InitTestDB(t)
+ cfg := setting.NewCfg()
+ cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
+ BaseInterval: time.Duration(rand.Int64N(100)) * time.Second,
+ }
+ folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
+ b := &fakeBus{}
+ orgID := int64(1)
+ ruleGen := models.RuleGen
+ ruleGen = ruleGen.With(
+ ruleGen.WithIntervalMatching(cfg.UnifiedAlerting.BaseInterval),
+ ruleGen.WithOrgID(orgID),
+ )
+ t.Run("filter by RuleType", func(t *testing.T) {
+ store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
+ alertingGen := ruleGen
+ recordingGen := ruleGen.With(models.RuleMuts.WithAllRecordingRules(), models.RuleMuts.WithMetric("metric1"), models.RuleMuts.WithRecordFrom("A"))
+
+ alertingRules := []*models.AlertRule{
+ createRule(t, store, alertingGen),
+ createRule(t, store, alertingGen),
+ }
+ recordingRules := []*models.AlertRule{
+ createRule(t, store, recordingGen),
+ createRule(t, store, recordingGen),
+ }
+
+ t.Run("should return only alerting rules", func(t *testing.T) {
+ query := &models.ListAlertRulesExtendedQuery{
+ ListAlertRulesQuery: models.ListAlertRulesQuery{
+ OrgID: orgID,
+ },
+ RuleType: models.RuleTypeFilterAlerting,
+ }
+ result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query)
+ require.NoError(t, err)
+ require.Empty(t, continueToken, "continue token should be empty when no pagination is applied")
+ require.NotEmpty(t, result)
+ for _, rule := range result {
+ require.Equal(t, models.RuleTypeAlerting, rule.Type())
+ }
+ })
+
+ t.Run("should return only recording rules", func(t *testing.T) {
+ query := &models.ListAlertRulesExtendedQuery{
+ ListAlertRulesQuery: models.ListAlertRulesQuery{
+ OrgID: orgID,
+ },
+ RuleType: models.RuleTypeFilterRecording,
+ }
+ result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query)
+ require.NoError(t, err)
+ require.Empty(t, continueToken, "continue token should be empty when no pagination is applied")
+ require.NotEmpty(t, result)
+ for _, rule := range result {
+ require.Equal(t, models.RuleTypeRecording, rule.Type())
+ }
+ })
+
+ t.Run("should return both alerting and recording rules when RuleType is not set", func(t *testing.T) {
+ query := &models.ListAlertRulesExtendedQuery{
+ ListAlertRulesQuery: models.ListAlertRulesQuery{
+ OrgID: orgID,
+ },
+ }
+ result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query)
+ require.NoError(t, err)
+ require.Empty(t, continueToken, "continue token should be empty when no pagination is applied")
+ require.NotEmpty(t, result)
+ var alertingCount, recordingCount int
+ for _, rule := range result {
+ switch rule.Type() {
+ case models.RuleTypeAlerting:
+ alertingCount++
+ case models.RuleTypeRecording:
+ recordingCount++
+ }
+ }
+ require.GreaterOrEqual(t, alertingCount, len(alertingRules))
+ require.GreaterOrEqual(t, recordingCount, len(recordingRules))
+ })
+ t.Run("should return both alerting and recording rules when RuleType is all", func(t *testing.T) {
+ query := &models.ListAlertRulesExtendedQuery{
+ ListAlertRulesQuery: models.ListAlertRulesQuery{
+ OrgID: orgID,
+ },
+ RuleType: models.RuleTypeFilterAll,
+ }
+ result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query)
+ require.NoError(t, err)
+ require.Empty(t, continueToken, "continue token should be empty when no pagination is applied")
+ require.NotEmpty(t, result)
+ var alertingCount, recordingCount int
+ for _, rule := range result {
+ switch rule.Type() {
+ case models.RuleTypeAlerting:
+ alertingCount++
+ case models.RuleTypeRecording:
+ recordingCount++
+ }
+ }
+ require.GreaterOrEqual(t, alertingCount, len(alertingRules))
+ require.GreaterOrEqual(t, recordingCount, len(recordingRules))
+ })
+ })
+ t.Run("list rules with pagination", func(t *testing.T) {
+ store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
+ alertingGen := ruleGen.With(ruleGen.WithNamespaceUID("paginate-test"))
+ for i := 0; i < 10; i++ {
+ createRule(t, store, alertingGen)
+ }
+ t.Run("should return paginated results", func(t *testing.T) {
+ query := &models.ListAlertRulesExtendedQuery{
+ ListAlertRulesQuery: models.ListAlertRulesQuery{
+ OrgID: orgID,
+ NamespaceUIDs: []string{"paginate-test"},
+ },
+ Limit: 5, // set page size to 5
+ }
+ result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query)
+ require.NoError(t, err)
+ require.Len(t, result, 5, "should return 5 rules as per page size")
+ require.NotEmpty(t, continueToken, "continue token should not be empty for paginated results")
+
+ // continue with the next page
+ query.ContinueToken = continueToken
+ result2, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query)
+ require.NoError(t, err)
+ require.Len(t, result2, 5, "should return next 5 rules")
+ require.Empty(t, continueToken, "continue token should be empty when all rules are fetched")
+
+ require.NotElementsMatch(t, result, result2, "should not have same rules in both pages")
+ })
+ })
+}
+
func TestIntegration_ListDeletedRules(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
diff --git a/pkg/services/ngalert/store/models.go b/pkg/services/ngalert/store/models.go
index f7b6858a86e..254ec8cb048 100644
--- a/pkg/services/ngalert/store/models.go
+++ b/pkg/services/ngalert/store/models.go
@@ -19,8 +19,8 @@ type alertRule struct {
DashboardUID *string `xorm:"dashboard_uid"`
PanelID *int64 `xorm:"panel_id"`
RuleGroup string
- RuleGroupIndex int `xorm:"rule_group_idx"`
- Record string
+ RuleGroupIndex int `xorm:"rule_group_idx"`
+ Record string // FIXME: record is nullable but we don't save it as null when it's nil
NoDataState string
ExecErrState string
For time.Duration
@@ -92,7 +92,11 @@ func (a alertRuleVersion) EqualSpec(b alertRuleVersion) bool {
a.IsPaused == b.IsPaused &&
a.NotificationSettings == b.NotificationSettings &&
a.Metadata == b.Metadata &&
- a.MissingSeriesEvalsToResolve == b.MissingSeriesEvalsToResolve
+ compareInt64Pointer(a.MissingSeriesEvalsToResolve, b.MissingSeriesEvalsToResolve)
+}
+
+func compareInt64Pointer(a, b *int64) bool {
+ return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
}
func (a alertRuleVersion) TableName() string {
diff --git a/pkg/services/ngalert/store/models_test.go b/pkg/services/ngalert/store/models_test.go
index e37598c3714..ddbc34b4036 100644
--- a/pkg/services/ngalert/store/models_test.go
+++ b/pkg/services/ngalert/store/models_test.go
@@ -71,6 +71,30 @@ func TestAlertRuleVersion_EqualSpec(t *testing.T) {
b: func() alertRuleVersion { v := baseVersion; v.MissingSeriesEvalsToResolve = nil; return v }(),
expect: true,
},
+ {
+ name: "same MissingSeriesEvalsToResolve value, different pointers",
+ a: func() alertRuleVersion {
+ v := baseVersion
+ v.MissingSeriesEvalsToResolve = util.Pointer(int64(10))
+ return v
+ }(),
+ b: func() alertRuleVersion {
+ v := baseVersion
+ v.MissingSeriesEvalsToResolve = util.Pointer(int64(10))
+ return v
+ }(),
+ expect: true,
+ },
+ {
+ name: "different MissingSeriesEvalsToResolve",
+ a: func() alertRuleVersion {
+ v := baseVersion
+ v.MissingSeriesEvalsToResolve = util.Pointer(int64(123))
+ return v
+ }(),
+ b: baseVersion,
+ expect: false,
+ },
{
name: "different NotificationSettings",
a: baseVersion,
diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go
index 8e7d92666a5..955f2c8091f 100644
--- a/pkg/services/ngalert/tests/fakes/rules.go
+++ b/pkg/services/ngalert/tests/fakes/rules.go
@@ -268,6 +268,22 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert
return outputRules, nextToken, nil
}
+// TODO: implement pagination for this fake
+func (f *RuleStore) ListAlertRulesPaginated(_ context.Context, q *models.ListAlertRulesExtendedQuery) (models.RulesGroup, string, error) {
+ f.mtx.Lock()
+ defer f.mtx.Unlock()
+ f.RecordedOps = append(f.RecordedOps, *q)
+
+ if err := f.Hook(*q); err != nil {
+ return nil, "", err
+ }
+ rules, err := f.listAlertRules(&q.ListAlertRulesQuery)
+ if err != nil {
+ return nil, "", err
+ }
+ return rules, "", nil
+}
+
func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQuery) (models.RulesGroup, error) {
f.mtx.Lock()
defer f.mtx.Unlock()
diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go
index 55edd021d00..05c242fb9c4 100644
--- a/pkg/services/provisioning/datasources/datasources.go
+++ b/pkg/services/provisioning/datasources/datasources.go
@@ -194,9 +194,12 @@ func (dc *DatasourceProvisioner) applyChanges(ctx context.Context, configPath st
func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string, OrgId int64) (correlations.CreateCorrelationCommand, error) {
// we look for a correlation type at the root if it is defined, if not use default
// we ignore the legacy config.type value - the only valid value at that version was "query"
- var corrType = correlation["type"]
- if corrType == nil || corrType == "" {
- corrType = correlations.CorrelationType("query")
+ var corrTypeStr = correlation["type"]
+ var corrType = correlations.CorrelationType("query")
+
+ // if corTypeStr is nil, an empty string, or query, leave it as query
+ if corrTypeStr == "external" {
+ corrType = correlations.CorrelationType("external")
}
var json = jsoniter.ConfigCompatibleWithStandardLibrary
@@ -206,7 +209,7 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string,
Description: correlation["description"].(string),
OrgId: OrgId,
Provisioned: true,
- Type: corrType.(correlations.CorrelationType),
+ Type: corrType,
}
targetUID, ok := correlation["targetUID"].(string)
@@ -230,8 +233,8 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string,
}
// config.type is a deprecated place for this value. We will default it to "query" for legacy purposes but non-query correlations should have type outside of config
- if config.Type != correlations.CorrelationType("query") {
- return correlations.CreateCorrelationCommand{}, correlations.ErrInvalidConfigType
+ if config.Type != "" && config.Type != correlations.CorrelationType("query") {
+ return correlations.CreateCorrelationCommand{}, correlations.ErrConfigTypeDeprecated
}
createCommand.Config = config
diff --git a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go
index 31e9530c959..2502caf51dc 100644
--- a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go
+++ b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go
@@ -210,4 +210,12 @@ func AddMigration(mg *migrator.Migrator) {
Type: migrator.UniqueIndex,
Cols: []string{"org_id", "user_id", "role_id"},
}))
+
+ mg.AddMigration("add permission role_id action index", migrator.NewAddIndexMigration(permissionV1, &migrator.Index{
+ Cols: []string{"role_id", "action"},
+ }))
+
+ mg.AddMigration("Remove permission role_id index", migrator.NewDropIndexMigration(permissionV1, &migrator.Index{
+ Cols: []string{"role_id"},
+ }))
}
diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go
index 18c7866cccf..af069db8fda 100644
--- a/pkg/setting/setting_jwt.go
+++ b/pkg/setting/setting_jwt.go
@@ -19,6 +19,7 @@ type AuthJWTSettings struct {
UsernameClaim string
ExpectClaims string
JWKSetURL string
+ JWKSetBearerTokenFile string
CacheTTL time.Duration
KeyFile string
KeyID string
@@ -33,6 +34,7 @@ type AuthJWTSettings struct {
GroupsAttributePath string
EmailAttributePath string
UsernameAttributePath string
+ TlsClientCa string
TlsSkipVerify bool
}
@@ -64,6 +66,7 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.UsernameClaim = valueAsString(authJWT, "username_claim", "")
jwtSettings.ExpectClaims = valueAsString(authJWT, "expect_claims", "{}")
jwtSettings.JWKSetURL = valueAsString(authJWT, "jwk_set_url", "")
+ jwtSettings.JWKSetBearerTokenFile = valueAsString(authJWT, "jwk_set_bearer_token_file", "")
jwtSettings.CacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60)
jwtSettings.KeyFile = valueAsString(authJWT, "key_file", "")
jwtSettings.KeyID = authJWT.Key("key_id").MustString("")
@@ -76,6 +79,7 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.GroupsAttributePath = valueAsString(authJWT, "groups_attribute_path", "")
jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "")
jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "")
+ jwtSettings.TlsClientCa = valueAsString(authJWT, "tls_client_ca", "")
jwtSettings.TlsSkipVerify = authJWT.Key("tls_skip_verify_insecure").MustBool(false)
jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "")
jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", ""))
diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go
index 45c32f2e462..091110f2c4e 100644
--- a/pkg/storage/unified/resource/search_test.go
+++ b/pkg/storage/unified/resource/search_test.go
@@ -382,7 +382,7 @@ func TestSearchGetOrCreateIndexWithIndexUpdate(t *testing.T) {
buildEmptyIndexCalls: []buildEmptyIndexCall{},
cache: map[NamespacedResource]ResourceIndex{
- NamespacedResource{Namespace: "ns", Group: "group", Resource: "bad"}: &MockResourceIndex{
+ {Namespace: "ns", Group: "group", Resource: "bad"}: &MockResourceIndex{
updateIndexError: failedErr,
},
},
diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go
index f5dc2b7f2a5..2152011d901 100644
--- a/pkg/storage/unified/resource/server.go
+++ b/pkg/storage/unified/resource/server.go
@@ -28,15 +28,6 @@ import (
"github.com/grafana/grafana/pkg/util/scheduler"
)
-const (
- // DefaultMaxBackoff is the default maximum backoff duration for enqueue operations.
- DefaultMaxBackoff = 1 * time.Second
- // DefaultMinBackoff is the default minimum backoff duration for enqueue operations.
- DefaultMinBackoff = 100 * time.Millisecond
- // DefaultMaxRetries is the default maximum number of retries for enqueue operations.
- DefaultMaxRetries = 3
-)
-
// ResourceServer implements all gRPC services
type ResourceServer interface {
resourcepb.ResourceStoreServer
@@ -162,6 +153,13 @@ type QOSEnqueuer interface {
Enqueue(ctx context.Context, tenantID string, runnable func()) error
}
+type QueueConfig struct {
+ MaxBackoff time.Duration
+ MinBackoff time.Duration
+ MaxRetries int
+ Timeout time.Duration
+}
+
type BlobConfig struct {
// The CDK configuration URL
URL string
@@ -240,7 +238,8 @@ type ResourceServerOptions struct {
MaxPageSizeBytes int
// QOSQueue is the quality of service queue used to enqueue
- QOSQueue QOSEnqueuer
+ QOSQueue QOSEnqueuer
+ QOSConfig QueueConfig
Ring *ring.Ring
RingLifecycler *ring.BasicLifecycler
@@ -281,6 +280,19 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
opts.QOSQueue = scheduler.NewNoopQueue()
}
+ if opts.QOSConfig.Timeout == 0 {
+ opts.QOSConfig.Timeout = 30 * time.Second
+ }
+ if opts.QOSConfig.MaxBackoff == 0 {
+ opts.QOSConfig.MaxBackoff = 1 * time.Second
+ }
+ if opts.QOSConfig.MinBackoff == 0 {
+ opts.QOSConfig.MinBackoff = 100 * time.Millisecond
+ }
+ if opts.QOSConfig.MaxRetries == 0 {
+ opts.QOSConfig.MaxRetries = 3
+ }
+
// Initialize the blob storage
blobstore := opts.Blob.Backend
if blobstore == nil {
@@ -326,6 +338,7 @@ func NewResourceServer(opts ResourceServerOptions) (*server, error) {
maxPageSizeBytes: opts.MaxPageSizeBytes,
reg: opts.Reg,
queue: opts.QOSQueue,
+ queueConfig: opts.QOSConfig,
}
if opts.Search.Resources != nil {
@@ -375,6 +388,7 @@ type server struct {
maxPageSizeBytes int
reg prometheus.Registerer
queue QOSEnqueuer
+ queueConfig QueueConfig
}
// Init implements ResourceServer.
@@ -635,8 +649,8 @@ func (s *server) Create(ctx context.Context, req *resourcepb.CreateRequest) (*re
res *resourcepb.CreateResponse
err error
)
- runErr := s.runInQueue(ctx, req.Key.Namespace, func() {
- res, err = s.create(ctx, user, req)
+ runErr := s.runInQueue(ctx, req.Key.Namespace, func(queueCtx context.Context) {
+ res, err = s.create(queueCtx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.CreateResponse {
@@ -689,8 +703,8 @@ func (s *server) Update(ctx context.Context, req *resourcepb.UpdateRequest) (*re
res *resourcepb.UpdateResponse
err error
)
- runErr := s.runInQueue(ctx, req.Key.Namespace, func() {
- res, err = s.update(ctx, user, req)
+ runErr := s.runInQueue(ctx, req.Key.Namespace, func(queueCtx context.Context) {
+ res, err = s.update(queueCtx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.UpdateResponse {
@@ -757,8 +771,8 @@ func (s *server) Delete(ctx context.Context, req *resourcepb.DeleteRequest) (*re
err error
)
- runErr := s.runInQueue(ctx, req.Key.Namespace, func() {
- res, err = s.delete(ctx, user, req)
+ runErr := s.runInQueue(ctx, req.Key.Namespace, func(queueCtx context.Context) {
+ res, err = s.delete(queueCtx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.DeleteResponse {
@@ -868,8 +882,8 @@ func (s *server) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resour
res *resourcepb.ReadResponse
err error
)
- runErr := s.runInQueue(ctx, req.Key.Namespace, func() {
- res, err = s.read(ctx, user, req)
+ runErr := s.runInQueue(ctx, req.Key.Namespace, func(queueCtx context.Context) {
+ res, err = s.read(queueCtx, user, req)
})
if runErr != nil {
return HandleQueueError(runErr, func(e *resourcepb.ErrorResult) *resourcepb.ReadResponse {
@@ -1375,40 +1389,44 @@ func (s *server) GetBlob(ctx context.Context, req *resourcepb.GetBlobRequest) (*
return rsp, nil
}
-func (s *server) runInQueue(ctx context.Context, tenantID string, runnable func()) error {
- boff := backoff.New(ctx, backoff.Config{
- MinBackoff: DefaultMinBackoff,
- MaxBackoff: DefaultMaxBackoff,
- MaxRetries: DefaultMaxRetries,
+func (s *server) runInQueue(ctx context.Context, tenantID string, runnable func(ctx context.Context)) error {
+ // Enforce a timeout for the entire operation, including queueing and execution.
+ queueCtx, cancel := context.WithTimeout(ctx, s.queueConfig.Timeout)
+ defer cancel()
+
+ done := make(chan struct{})
+ wrappedRunnable := func() {
+ defer close(done)
+ runnable(queueCtx)
+ }
+
+ // Retry enqueueing with backoff, respecting the timeout context.
+ boff := backoff.New(queueCtx, backoff.Config{
+ MinBackoff: s.queueConfig.MinBackoff,
+ MaxBackoff: s.queueConfig.MaxBackoff,
+ MaxRetries: s.queueConfig.MaxRetries,
})
- var (
- wg sync.WaitGroup
- err error
- )
- wg.Add(1)
- wrapped := func() {
- defer wg.Done()
- runnable()
- }
- for boff.Ongoing() {
- err = s.queue.Enqueue(ctx, tenantID, wrapped)
+ for {
+ err := s.queue.Enqueue(queueCtx, tenantID, wrappedRunnable)
if err == nil {
+ // Successfully enqueued.
break
}
- s.log.Warn("failed to enqueue runnable, retrying",
- "maxRetries", DefaultMaxRetries,
- "tenantID", tenantID,
- "error", err)
+
+ s.log.Warn("failed to enqueue runnable, retrying", "tenantID", tenantID, "error", err)
+ if !boff.Ongoing() {
+ // Backoff finished (retries exhausted or context canceled).
+ return fmt.Errorf("failed to enqueue for tenant %s: %w", tenantID, err)
+ }
boff.Wait()
}
- if err != nil {
- s.log.Error("failed to enqueue runnable",
- "maxRetries", DefaultMaxRetries,
- "tenantID", tenantID,
- "error", err)
- return fmt.Errorf("failed to enqueue runnable for tenant %s: %w", tenantID, err)
+
+ // Wait for the runnable to complete or for the context to be done.
+ select {
+ case <-done:
+ return nil // Completed successfully.
+ case <-queueCtx.Done():
+ return queueCtx.Err() // Timed out or canceled while waiting for execution.
}
- wg.Wait()
- return nil
}
diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go
index b0dff754a18..98bc928da63 100644
--- a/pkg/storage/unified/resource/server_test.go
+++ b/pkg/storage/unified/resource/server_test.go
@@ -4,20 +4,27 @@ import (
"context"
"encoding/json"
"fmt"
+ "log/slog"
"net/http"
"os"
+ "sync"
"testing"
"time"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
authlib "github.com/grafana/authlib/types"
+ "github.com/grafana/dskit/services"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
+ "github.com/grafana/grafana/pkg/util/scheduler"
)
func TestSimpleServer(t *testing.T) {
@@ -242,3 +249,105 @@ func TestSimpleServer(t *testing.T) {
require.ErrorIs(t, err, ErrOptimisticLockingFailed)
})
}
+
+func TestRunInQueue(t *testing.T) {
+ const testTenantID = "test-tenant"
+ t.Run("should execute successfully when queue has capacity", func(t *testing.T) {
+ s, _ := newTestServerWithQueue(t, 1, 1)
+ executed := make(chan bool, 1)
+
+ runnable := func(ctx context.Context) {
+ executed <- true
+ }
+
+ err := s.runInQueue(context.Background(), testTenantID, runnable)
+ require.NoError(t, err)
+ assert.True(t, <-executed, "runnable should have been executed")
+ })
+
+ t.Run("should time out if a task is sitting in the queue beyond the timeout", func(t *testing.T) {
+ s, _ := newTestServerWithQueue(t, 1, 1)
+ executed := make(chan struct{}, 1)
+ runnable := func(ctx context.Context) {
+ time.Sleep(1 * time.Second)
+ executed <- struct{}{}
+ }
+
+ err := s.runInQueue(context.Background(), testTenantID, runnable)
+ require.Error(t, err)
+ assert.Equal(t, context.DeadlineExceeded, err)
+ <-executed
+ })
+
+ t.Run("should return an error if queue is consistently full after retrying", func(t *testing.T) {
+ s, q := newTestServerWithQueue(t, 1, 1)
+ // Task 1: This will be picked up by the worker and block it.
+ blocker := make(chan struct{})
+ defer close(blocker)
+ blockingRunnable := func() {
+ <-blocker
+ }
+ err := q.Enqueue(context.Background(), testTenantID, blockingRunnable)
+ require.NoError(t, err)
+ for q.Len() > 0 {
+ time.Sleep(100 * time.Millisecond)
+ }
+ err = q.Enqueue(context.Background(), testTenantID, blockingRunnable)
+ require.NoError(t, err)
+
+ // Task 2: This runnable should never execute because the queue is full.
+ mu := sync.Mutex{}
+ executed := false
+ runnable := func(ctx context.Context) {
+ mu.Lock()
+ defer mu.Unlock()
+ executed = true
+ }
+
+ err = s.runInQueue(context.Background(), testTenantID, runnable)
+ require.Error(t, err)
+ require.ErrorIs(t, err, scheduler.ErrTenantQueueFull)
+ require.False(t, executed, "runnable should not have been executed")
+ })
+}
+
+// newTestServerWithQueue creates a server with a real scheduler.Queue for testing.
+// It also sets up a worker to consume items from the queue.
+func newTestServerWithQueue(t *testing.T, maxSizePerTenant int, numWorkers int) (*server, *scheduler.Queue) {
+ t.Helper()
+ q := scheduler.NewQueue(&scheduler.QueueOptions{
+ MaxSizePerTenant: maxSizePerTenant,
+ Registerer: prometheus.NewRegistry(),
+ Logger: log.NewNopLogger(),
+ })
+ err := services.StartAndAwaitRunning(context.Background(), q)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ err := services.StopAndAwaitTerminated(context.Background(), q)
+ require.NoError(t, err)
+ })
+
+ // Create a worker to consume from the queue
+ worker, err := scheduler.NewScheduler(q, &scheduler.Config{
+ Logger: log.NewNopLogger(),
+ NumWorkers: numWorkers,
+ })
+ require.NoError(t, err)
+ err = services.StartAndAwaitRunning(context.Background(), worker)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ err := services.StopAndAwaitTerminated(context.Background(), worker)
+ require.NoError(t, err)
+ })
+
+ s := &server{
+ queue: q,
+ queueConfig: QueueConfig{
+ Timeout: 500 * time.Millisecond,
+ MaxRetries: 2,
+ MinBackoff: 10 * time.Millisecond,
+ },
+ log: slog.Default(),
+ }
+ return s, q
+}
diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go
index 2560943c92b..be6789844e6 100644
--- a/pkg/storage/unified/sql/backend.go
+++ b/pkg/storage/unified/sql/backend.go
@@ -709,13 +709,6 @@ func (b *backend) ListModifiedSince(ctx context.Context, key resource.Namespaced
continue
}
- if mr.Key.Name <= lastSeen {
- // resource names should be sorted alphabetically. So if not, the query is not correct.
- if !yield(nil, fmt.Errorf("listModifiedSince: resources are not sorted by name ASC, lastSeen: %q, current: %q", lastSeen, mr.Key.Name)) {
- return
- }
- }
-
lastSeen = mr.Key.Name
if !yield(mr, nil) {
diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go
index 5488b44a144..d7f693fd991 100644
--- a/pkg/storage/unified/testing/storage_backend.go
+++ b/pkg/storage/unified/testing/storage_backend.go
@@ -11,6 +11,7 @@ import (
"github.com/go-jose/go-jose/v3/jwt"
"github.com/google/uuid"
+ "github.com/grafana/grafana/pkg/infra/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -524,7 +525,7 @@ func runTestIntegrationBackendListModifiedSince(t *testing.T, backend resource.S
require.GreaterOrEqual(t, latestRv, rvDeleted)
counter := 0
- for _, _ = range seq {
+ for range seq {
counter++
}
require.Equal(t, 0, counter) // no events should be returned
@@ -553,6 +554,39 @@ func runTestIntegrationBackendListModifiedSince(t *testing.T, backend resource.S
}
require.Equal(t, 1, counter) // only one event should be returned
})
+
+ t.Run("will order events by resource version ascending and name descending", func(t *testing.T) {
+ // When we order by name ASC, sqlite orders upper case strings first - so skipping for sqlite
+ // For example: for this test, the actual ordering of events for sqlite is CItem, aItem, bItem
+ if db.IsTestDbSQLite() {
+ t.Skip("Skipping test for sqlite since ordering by name is different than mysql due to case sensitivity")
+ }
+
+ key := resource.NamespacedResource{
+ Namespace: ns,
+ Group: "group",
+ Resource: "resource",
+ }
+
+ rvCreated1, _ := writeEvent(ctx, backend, "aItem", resourcepb.WatchEvent_ADDED, WithNamespace(ns))
+ rvCreated2, _ := writeEvent(ctx, backend, "bItem", resourcepb.WatchEvent_ADDED, WithNamespace(ns))
+ rvCreated3, _ := writeEvent(ctx, backend, "CItem", resourcepb.WatchEvent_ADDED, WithNamespace(ns))
+
+ latestRv, seq := backend.ListModifiedSince(ctx, key, rvCreated1-1)
+ require.Greater(t, latestRv, rvCreated3)
+
+ counter := 0
+ names := []string{"aItem", "bItem", "CItem"}
+ rvs := []int64{rvCreated1, rvCreated2, rvCreated3}
+ for res, err := range seq {
+ require.NoError(t, err)
+ require.Equal(t, key.Namespace, res.Key.Namespace)
+ require.Equal(t, names[counter], res.Key.Name)
+ require.Equal(t, rvs[counter], res.ResourceVersion)
+ counter++
+ }
+ require.Equal(t, 3, counter)
+ })
}
func runTestIntegrationBackendListHistory(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
diff --git a/pkg/tests/api/correlations/correlations_provisioning_api_test.go b/pkg/tests/api/correlations/correlations_provisioning_api_test.go
index e8c1f1a5d8a..2146652f5d4 100644
--- a/pkg/tests/api/correlations/correlations_provisioning_api_test.go
+++ b/pkg/tests/api/correlations/correlations_provisioning_api_test.go
@@ -175,6 +175,6 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
})
require.Error(t, err)
- require.ErrorIs(t, err, correlations.ErrInvalidConfigType)
+ require.ErrorIs(t, err, correlations.ErrConfigTypeDeprecated)
})
}
diff --git a/public/app/features/connections/Connections.test.tsx b/public/app/features/connections/Connections.test.tsx
index 3778042af10..1b92b5dfd1c 100644
--- a/public/app/features/connections/Connections.test.tsx
+++ b/public/app/features/connections/Connections.test.tsx
@@ -51,7 +51,7 @@ describe('Connections', () => {
// Add new connection card
expect(await screen.findByText('Add new connection')).toBeVisible();
- expect(await screen.findByText('Collector:')).toBeVisible();
+ expect(await screen.findByText('Collector')).toBeVisible();
expect(await screen.findByText('Data sources')).toBeVisible();
expect(await screen.findByText('Integrations')).toBeVisible();
expect(await screen.findByText('Private data source connect')).toBeVisible();
diff --git a/public/app/features/connections/components/PageCard/CardData.ts b/public/app/features/connections/components/PageCard/CardData.ts
index 8355fdc5aa8..670072a5ee9 100644
--- a/public/app/features/connections/components/PageCard/CardData.ts
+++ b/public/app/features/connections/components/PageCard/CardData.ts
@@ -20,7 +20,7 @@ export function getCloudCardData(): CardData[] {
icon: 'plus-circle',
},
{
- text: t('connections.cloud.connections-home-page.collector.title', 'Collector:'),
+ text: t('connections.cloud.connections-home-page.collector.title', 'Collector'),
subTitle: t(
'connections.cloud.connections-home-page.collector.subtitle',
'Manage the configuration of Grafana Alloy, our distribution of the OpenTelemetry Collector'
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx
index 4295ddb38d4..d46b7270cb5 100644
--- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx
+++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx
@@ -405,7 +405,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps
- Add query from library
+ Add from saved queries
)}
>
diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx
index 50e997ad80e..e5bce32bd86 100644
--- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx
+++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx
@@ -10,12 +10,13 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLineDetailsMode } from './LogLineDetails';
import { useLogListContext } from './LogListContext';
+import { reportInteractionOnce } from './analytics';
export const LogLineDetailsDisplayedFields = () => {
const { displayedFields, setDisplayedFields } = useLogListContext();
const reorganizeDisplayedFields = useCallback(
- (srcIndex: number, destIndex: number) => {
+ (srcIndex: number, destIndex: number, mode: 'drag' | 'button') => {
const newDisplayedFields = [...displayedFields];
const element = displayedFields[srcIndex];
@@ -23,6 +24,11 @@ export const LogLineDetailsDisplayedFields = () => {
newDisplayedFields.splice(destIndex, 0, element);
setDisplayedFields?.(newDisplayedFields);
+
+ reportInteractionOnce('logs_log_line_details_organized_fields', {
+ quantity: newDisplayedFields.length,
+ mode,
+ });
},
[displayedFields, setDisplayedFields]
);
@@ -32,7 +38,7 @@ export const LogLineDetailsDisplayedFields = () => {
if (result.destination == null) {
return;
}
- reorganizeDisplayedFields(result.source.index, result.destination.index);
+ reorganizeDisplayedFields(result.source.index, result.destination.index, 'drag');
},
[reorganizeDisplayedFields]
);
@@ -65,7 +71,7 @@ export const LogLineDetailsDisplayedFields = () => {
interface DraggableDisplayedFieldProps {
field: string;
index: number;
- moveField: (srcIndex: number, destIndex: number) => void;
+ moveField: (srcIndex: number, destIndex: number, mode: 'drag' | 'button') => void;
}
const DraggableDisplayedField = ({ field, index, moveField }: DraggableDisplayedFieldProps) => {
@@ -99,12 +105,12 @@ const DisplayedField = ({
<>
moveField(index, nextIndex)}
+ onClick={() => moveField(index, nextIndex, 'button')}
tooltip={t('logs.log-line-details.move-displayed-field-down', 'Move down')}
/>
moveField(index, prevIndex)}
+ onClick={() => moveField(index, prevIndex, 'button')}
tooltip={t('logs.log-line-details.move-displayed-field-up', 'Move up')}
/>
>
diff --git a/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx b/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx
index a9f80b841a4..28f49069dba 100644
--- a/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx
+++ b/public/app/features/logs/components/panel/LogLineDetailsTrace.tsx
@@ -4,7 +4,7 @@ import { isObservable, lastValueFrom } from 'rxjs';
import { DataFrame, DataQueryRequest, DataSourceApi, GrafanaTheme2, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
-import { getDataSourceSrv } from '@grafana/runtime';
+import { getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { TraceView } from 'app/features/explore/TraceView/TraceView';
import { transformDataFrames } from 'app/features/explore/TraceView/utils/transform';
@@ -76,6 +76,14 @@ export const LogLineDetailsTrace = ({ timeRange, timeZone, traceRef }: Props) =>
const traceProp = useMemo(() => (dataFrames?.length ? transformDataFrames(dataFrames[0]) : undefined), [dataFrames]);
+ useEffect(() => {
+ if (dataSource && Array.isArray(dataFrames) && traceProp) {
+ reportInteraction('logs_log_line_details_trace_displayed');
+ } else if (dataFrames === null) {
+ reportInteraction('logs_log_line_details_trace_display_failed');
+ }
+ }, [dataFrames, dataSource, traceProp]);
+
return (
{dataSource && Array.isArray(dataFrames) && traceProp && (
diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx
index 5ba76f50518..7e5c4154213 100644
--- a/public/app/features/logs/components/panel/LogListContext.tsx
+++ b/public/app/features/logs/components/panel/LogListContext.tsx
@@ -24,7 +24,7 @@ import {
store,
} from '@grafana/data';
import { t } from '@grafana/i18n';
-import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
+import { config, getDataSourceSrv } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
@@ -34,6 +34,7 @@ import { LogLineTimestampResolution } from './LogLine';
import { LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListFontSize } from './LogList';
+import { reportInteractionOnce } from './analytics';
import { LogListModel } from './processing';
import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization';
@@ -710,15 +711,6 @@ export function removeDetailsScrollPosition(log: LogListModel) {
detailsScrollMap.delete(log.uid);
}
-const reportInteractionOnce = (interactionName: string, properties?: Record
) => {
- const key = `logs.log-list-context.events.${interactionName}`;
- if (sessionStorage.getItem(key)) {
- return;
- }
- sessionStorage.setItem(key, '1');
- reportInteraction(interactionName, properties);
-};
-
async function handleOpenAssistant(openAssistant: (props: OpenAssistantProps) => void, log: LogListModel) {
const datasource = await getDataSourceSrv().get(log.datasourceUid);
const context = [];
diff --git a/public/app/features/logs/components/panel/analytics.ts b/public/app/features/logs/components/panel/analytics.ts
new file mode 100644
index 00000000000..39ec415b939
--- /dev/null
+++ b/public/app/features/logs/components/panel/analytics.ts
@@ -0,0 +1,10 @@
+import { reportInteraction } from '@grafana/runtime';
+
+export const reportInteractionOnce = (interactionName: string, properties?: Record) => {
+ const key = `logs.interactions.${interactionName}`;
+ if (sessionStorage.getItem(key)) {
+ return;
+ }
+ sessionStorage.setItem(key, '1');
+ reportInteraction(interactionName, properties);
+};
diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts
index fbf9c432609..b0f38068588 100644
--- a/public/app/features/plugins/built_in_plugins.ts
+++ b/public/app/features/plugins/built_in_plugins.ts
@@ -1,5 +1,3 @@
-import { config } from '@grafana/runtime';
-
const graphitePlugin = async () =>
await import(/* webpackChunkName: "graphitePlugin" */ 'app/plugins/datasource/graphite/module');
const cloudwatchPlugin = async () =>
@@ -56,13 +54,7 @@ const stateTimelinePanel = async () =>
await import(/* webpackChunkName: "stateTimelinePanel" */ 'app/plugins/panel/state-timeline/module');
const statusHistoryPanel = async () =>
await import(/* webpackChunkName: "statusHistoryPanel" */ 'app/plugins/panel/status-history/module');
-const tablePanel = async () => {
- if (config.featureToggles.tableNextGen) {
- return await import(/* webpackChunkName: "tableNewPanel" */ 'app/plugins/panel/table/table-new/module');
- } else {
- return await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
- }
-};
+const tablePanel = async () => await import(/* webpackChunkName: "tablePanel" */ 'app/plugins/panel/table/module');
const textPanel = async () => await import(/* webpackChunkName: "textPanel" */ 'app/plugins/panel/text/module');
const timeseriesPanel = async () =>
await import(/* webpackChunkName: "timeseriesPanel" */ 'app/plugins/panel/timeseries/module');
diff --git a/public/app/plugins/panel/table/PaginationEditor.tsx b/public/app/plugins/panel/table/PaginationEditor.tsx
index 0bfa20b565b..ccd9451ee3f 100644
--- a/public/app/plugins/panel/table/PaginationEditor.tsx
+++ b/public/app/plugins/panel/table/PaginationEditor.tsx
@@ -1,15 +1,19 @@
import * as React from 'react';
import { StandardEditorProps } from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
import { Switch } from '@grafana/ui';
-export function PaginationEditor({ onChange, value, context }: StandardEditorProps) {
+export function PaginationEditor({ onChange, value }: StandardEditorProps) {
const changeValue = (event: React.FormEvent | undefined) => {
- if (event?.currentTarget.checked) {
- context.options.footer.show = false;
- }
onChange(event?.currentTarget.checked);
};
- return ;
+ return (
+
+ );
}
diff --git a/public/app/plugins/panel/table/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/TableCellOptionEditor.tsx
index c0fd5e0e5ea..b70723695cb 100644
--- a/public/app/plugins/panel/table/TableCellOptionEditor.tsx
+++ b/public/app/plugins/panel/table/TableCellOptionEditor.tsx
@@ -2,15 +2,17 @@ import { css } from '@emotion/css';
import { merge } from 'lodash';
import { useState } from 'react';
-import { GrafanaTheme2, SelectableValue } from '@grafana/data';
-import { TableCellOptions } from '@grafana/schema';
-import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
+import { GrafanaTheme2 } from '@grafana/data';
+import { t } from '@grafana/i18n';
+import { TableCellOptions, TableWrapTextOptions } from '@grafana/schema';
+import { Combobox, ComboboxOption, Field, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
-import { AutoCellOptionsEditor } from './cells/AutoCellOptionsEditor';
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
+import { MarkdownCellOptionsEditor } from './cells/MarkdownCellOptionsEditor';
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
+import { TextWrapOptionsEditor } from './cells/TextWrapOptionsEditor';
// The props that any cell type editor are expected
// to handle. In this case the generic type should
@@ -25,18 +27,48 @@ interface Props {
onChange: (v: TableCellOptions) => void;
}
+const TEXT_WRAP_CELL_TYPES = new Set([
+ TableCellDisplayMode.Auto,
+ TableCellDisplayMode.Sparkline,
+ TableCellDisplayMode.ColorText,
+ TableCellDisplayMode.ColorBackground,
+ TableCellDisplayMode.DataLinks,
+ TableCellDisplayMode.Pill,
+]);
+
+function isTextWrapCellType(value: TableCellOptions): value is TableCellOptions & TableWrapTextOptions {
+ return TEXT_WRAP_CELL_TYPES.has(value.type);
+}
+
export const TableCellOptionEditor = ({ value, onChange }: Props) => {
const cellType = value.type;
const styles = useStyles2(getStyles);
- const currentMode = cellDisplayModeOptions.find((o) => o.value!.type === cellType)!;
+ const cellDisplayModeOptions: Array> = [
+ { value: TableCellDisplayMode.Auto, label: t('table.cell-types.auto', 'Auto') },
+ { value: TableCellDisplayMode.ColorText, label: t('table.cell-types.color-text', 'Colored text') },
+ {
+ value: TableCellDisplayMode.ColorBackground,
+ label: t('table.cell-types.color-background', 'Colored background'),
+ },
+ { value: TableCellDisplayMode.DataLinks, label: t('table.cell-types.data-links', 'Data links') },
+ { value: TableCellDisplayMode.Gauge, label: t('table.cell-types.gauge', 'Gauge') },
+ { value: TableCellDisplayMode.Sparkline, label: t('table.cell-types.sparkline', 'Sparkline') },
+ { value: TableCellDisplayMode.JSONView, label: t('table.cell-types.json', 'JSON View') },
+ { value: TableCellDisplayMode.Pill, label: t('table.cell-types.pill', 'Pill') },
+ { value: TableCellDisplayMode.Markdown, label: t('table.cell-types.markdown', 'Markdown + HTML') },
+ { value: TableCellDisplayMode.Image, label: t('table.cell-types.image', 'Image') },
+ { value: TableCellDisplayMode.Actions, label: t('table.cell-types.actions', 'Actions') },
+ ];
+ const currentMode = cellDisplayModeOptions.find((o) => o.value === cellType)!;
+
let [settingCache, setSettingCache] = useState>({});
// Update display mode on change
- const onCellTypeChange = (v: SelectableValue) => {
- if (v.value !== undefined) {
+ const onCellTypeChange = (v: ComboboxOption) => {
+ if (v !== null) {
// Set the new type of cell starting
// with default settings
- value = v.value;
+ value = { type: v.value };
// When changing cell type see if there were previously stored
// settings and merge those with the changed value
@@ -60,11 +92,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
return (
-
+
- {(cellType === TableCellDisplayMode.Auto || cellType === TableCellDisplayMode.ColorText) && (
-
- )}
+ {isTextWrapCellType(value) &&
}
{cellType === TableCellDisplayMode.Gauge && (
)}
@@ -77,24 +107,16 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
{cellType === TableCellDisplayMode.Image && (
)}
+ {cellType === TableCellDisplayMode.Markdown && (
+
+ )}
);
};
-let cellDisplayModeOptions: Array> = [
- { value: { type: TableCellDisplayMode.Auto }, label: 'Auto' },
- { value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' },
- { value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' },
- { value: { type: TableCellDisplayMode.DataLinks }, label: 'Data links' },
- { value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' },
- { value: { type: TableCellDisplayMode.Sparkline }, label: 'Sparkline' },
- { value: { type: TableCellDisplayMode.JSONView }, label: 'JSON View' },
- { value: { type: TableCellDisplayMode.Image }, label: 'Image' },
- { value: { type: TableCellDisplayMode.Actions }, label: 'Actions' },
-];
-
const getStyles = (theme: GrafanaTheme2) => ({
fixBottomMargin: css({
+ position: 'relative',
marginBottom: theme.spacing(-2),
}),
});
diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx
index a5dfbf92c63..bf45bed58cd 100644
--- a/public/app/plugins/panel/table/TablePanel.tsx
+++ b/public/app/plugins/panel/table/TablePanel.tsx
@@ -1,4 +1,5 @@
import { css } from '@emotion/css';
+import { useCallback, useMemo } from 'react';
import {
ActionModel,
@@ -10,10 +11,13 @@ import {
PanelProps,
SelectableValue,
Field,
+ cacheFieldDisplayNames,
} from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
-import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
+import { Select, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/internal';
+import { TableNG } from '@grafana/ui/unstable';
+import { getConfig } from 'app/core/config';
import { getActions } from '../../../features/actions/utils';
@@ -23,10 +27,18 @@ import { Options } from './panelcfg.gen';
interface Props extends PanelProps {}
export function TablePanel(props: Props) {
- const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables } = props;
+ const { data, height, width, options, fieldConfig, id, timeRange, replaceVariables, transparent } = props;
+
+ useMemo(() => {
+ cacheFieldDisplayNames(data.series);
+ }, [data.series]);
const theme = useTheme2();
const panelContext = usePanelContext();
+ const _getActions = useCallback(
+ (frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables),
+ [replaceVariables]
+ );
const frames = hasDeprecatedParentRowIndex(data.series)
? migrateFromParentRowIndexToNestedFrames(data.series)
: data.series;
@@ -50,8 +62,10 @@ export function TablePanel(props: Props) {
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
+ const disableSanitizeHtml = getConfig().disableSanitizeHtml;
+
const tableElement = (
- onColumnResize(displayName, resizedWidth, props)}
onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer}
+ frozenColumns={options.frozenColumns?.left}
enablePagination={options.footer?.enablePagination}
cellHeight={options.cellHeight}
timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
fieldConfig={fieldConfig}
- getActions={getCellActions}
- replaceVariables={replaceVariables}
+ getActions={_getActions}
+ structureRev={data.structureRev}
+ transparent={transparent}
+ disableSanitizeHtml={disableSanitizeHtml}
/>
);
@@ -151,28 +168,39 @@ const getCellActions = (
field: Field,
rowIndex: number,
replaceVariables: InterpolateFunction | undefined
-) => {
- const actions: Array> = [];
- const actionLookup = new Set();
+): Array> => {
+ const numActions = field.config.actions?.length ?? 0;
- const actionsModel = getActions(
- dataFrame,
- field,
- field.state!.scopedVars!,
- replaceVariables ?? replaceVars,
- field.config.actions ?? [],
- { valueRowIndex: rowIndex }
- );
+ if (numActions > 0) {
+ const actions = getActions(
+ dataFrame,
+ field,
+ field.state!.scopedVars!,
+ replaceVariables ?? replaceVars,
+ field.config.actions ?? [],
+ { valueRowIndex: rowIndex }
+ );
- actionsModel.forEach((action) => {
- const key = `${action.title}`;
- if (!actionLookup.has(key)) {
- actions.push(action);
- actionLookup.add(key);
+ if (actions.length === 1) {
+ return actions;
+ } else {
+ const actionsOut: Array> = [];
+ const actionLookup = new Set();
+
+ actions.forEach((action) => {
+ const key = action.title;
+
+ if (!actionLookup.has(key)) {
+ actionsOut.push(action);
+ actionLookup.add(key);
+ }
+ });
+
+ return actionsOut;
}
- });
+ }
- return actions;
+ return [];
};
const tableStyles = {
diff --git a/public/app/plugins/panel/table/cells/AutoCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/AutoCellOptionsEditor.tsx
deleted file mode 100644
index e97d481e0b3..00000000000
--- a/public/app/plugins/panel/table/cells/AutoCellOptionsEditor.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { t } from '@grafana/i18n';
-import { TableAutoCellOptions, TableColorTextCellOptions } from '@grafana/schema';
-import { Field, Switch } from '@grafana/ui';
-
-import { TableCellEditorProps } from '../TableCellOptionEditor';
-
-export const AutoCellOptionsEditor = ({
- cellOptions,
- onChange,
-}: TableCellEditorProps) => {
- const onWrapTextChange = () => {
- cellOptions.wrapText = !cellOptions.wrapText;
- onChange(cellOptions);
- };
-
- return (
-
-
-
- );
-};
diff --git a/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx
index 33c3761493d..33d85539ec4 100644
--- a/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx
+++ b/public/app/plugins/panel/table/cells/ColorBackgroundCellOptionsEditor.tsx
@@ -1,63 +1,44 @@
import { SelectableValue } from '@grafana/data';
-import { Trans, t } from '@grafana/i18n';
+import { selectors } from '@grafana/e2e-selectors';
+import { t } from '@grafana/i18n';
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
-import { Field, RadioButtonGroup, Switch, Label, Badge } from '@grafana/ui';
+import { Field, RadioButtonGroup, Switch } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
+import { TextWrapOptionsEditor } from './TextWrapOptionsEditor';
+
const colorBackgroundOpts: Array> = [
{ value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' },
{ value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' },
];
-
export const ColorBackgroundCellOptionsEditor = ({
cellOptions,
onChange,
}: TableCellEditorProps) => {
// Set the display mode on change
-
const onCellOptionsChange = (v: TableCellBackgroundDisplayMode) => {
cellOptions.mode = v;
onChange(cellOptions);
};
-
const onColorRowChange = () => {
cellOptions.applyToRow = !cellOptions.applyToRow;
onChange(cellOptions);
};
- const onWrapTextChange = () => {
- cellOptions.wrapText = !cellOptions.wrapText;
- onChange(cellOptions);
- };
-
- const label = (
-
- );
-
return (
<>
+
-
-
-
-
+
+
+ {
+ cellOptions.wrapText = updatedCellOptions.wrapText;
+ onChange(cellOptions);
+ }}
+ />
>
);
};
diff --git a/public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/MarkdownCellOptionsEditor.tsx
similarity index 100%
rename from public/app/plugins/panel/table/table-new/cells/MarkdownCellOptionsEditor.tsx
rename to public/app/plugins/panel/table/cells/MarkdownCellOptionsEditor.tsx
diff --git a/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx b/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx
index bb5b0fd3e7e..c693b9bf4e9 100644
--- a/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx
+++ b/public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data';
import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
-import { Stack, Field, useStyles2 } from '@grafana/ui';
+import { Field, useStyles2, Stack } from '@grafana/ui';
import { defaultSparklineCellConfig } from '@grafana/ui/internal';
import { getGraphFieldConfig } from '../../timeseries/config';
@@ -52,7 +52,7 @@ export const SparklineCellOptionsEditor = (props: TableCellEditorProps
+
{registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => {
if (item.showIf && !item.showIf(values)) {
return null;
diff --git a/public/app/plugins/panel/table/table-new/cells/TextWrapOptionsEditor.tsx b/public/app/plugins/panel/table/cells/TextWrapOptionsEditor.tsx
similarity index 100%
rename from public/app/plugins/panel/table/table-new/cells/TextWrapOptionsEditor.tsx
rename to public/app/plugins/panel/table/cells/TextWrapOptionsEditor.tsx
diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx
index 4b55b60031b..d1621647080 100644
--- a/public/app/plugins/panel/table/module.tsx
+++ b/public/app/plugins/panel/table/module.tsx
@@ -9,7 +9,13 @@ import {
FieldConfigProperty,
} from '@grafana/data';
import { t } from '@grafana/i18n';
-import { TableCellOptions, TableCellDisplayMode, defaultTableFieldOptions, TableCellHeight } from '@grafana/schema';
+import {
+ TableCellOptions,
+ TableCellDisplayMode,
+ defaultTableFieldOptions,
+ TableCellHeight,
+ TableCellTooltipPlacement,
+} from '@grafana/schema';
import { PaginationEditor } from './PaginationEditor';
import { TableCellOptionEditor } from './TableCellOptionEditor';
@@ -102,18 +108,64 @@ export const plugin = new PanelPlugin(TablePanel)
description: t('table.description-column-filter', 'Enables/disables field filters in table'),
defaultValue: defaultTableFieldOptions.filterable,
})
+ .addBooleanSwitch({
+ path: 'wrapHeaderText',
+ name: t('table.name-wrap-header-text', 'Wrap header text'),
+ description: t('table.description-wrap-header-text', 'Enables text wrapping for column headers'),
+ category,
+ defaultValue: undefined,
+ })
.addBooleanSwitch({
path: 'hidden',
name: t('table.name-hide-in-table', 'Hide in table'),
category,
defaultValue: undefined,
hideFromDefaults: true,
+ })
+ .addFieldNamePicker({
+ path: 'tooltip.field',
+ name: t('table.name-tooltip-from-field', 'Tooltip from field'),
+ description: t(
+ 'table.description-tooltip-from-field',
+ 'Render a cell from a field (hidden or visible) in a tooltip'
+ ),
+ category: cellCategory,
+ })
+ .addSelect({
+ path: 'tooltip.placement',
+ name: t('table.name-tooltip-placement', 'Tooltip placement'),
+ category: cellCategory,
+ settings: {
+ options: [
+ {
+ label: t('table.tooltip-placement-options.label-auto', 'Auto'),
+ value: TableCellTooltipPlacement.Auto,
+ },
+ {
+ label: t('table.tooltip-placement-options.label-top', 'Top'),
+ value: TableCellTooltipPlacement.Top,
+ },
+ {
+ label: t('table.tooltip-placement-options.label-right', 'Right'),
+ value: TableCellTooltipPlacement.Right,
+ },
+ {
+ label: t('table.tooltip-placement-options.label-bottom', 'Bottom'),
+ value: TableCellTooltipPlacement.Bottom,
+ },
+ {
+ label: t('table.tooltip-placement-options.label-left', 'Left'),
+ value: TableCellTooltipPlacement.Left,
+ },
+ ],
+ },
+ showIf: (cfg) => cfg.tooltip?.field !== undefined,
});
},
})
.setPanelOptions((builder) => {
- const category = [t('table.category-table', 'Table')];
const footerCategory = [t('table.category-table-footer', 'Table footer')];
+ const category = [t('table.category-table', 'Table')];
builder
.addBooleanSwitch({
path: 'showHeader',
@@ -121,6 +173,15 @@ export const plugin = new PanelPlugin(TablePanel)
category,
defaultValue: defaultOptions.showHeader,
})
+ .addNumberInput({
+ path: 'frozenColumns.left',
+ name: t('table.name-frozen-columns', 'Frozen columns'),
+ description: t('table.description-frozen-columns', 'Columns are frozen from the left side of the table'),
+ settings: {
+ placeholder: 'none',
+ },
+ category,
+ })
.addRadio({
path: 'cellHeight',
name: t('table.name-cell-height', 'Cell height'),
@@ -188,7 +249,7 @@ export const plugin = new PanelPlugin(TablePanel)
.addCustomEditor({
id: 'footer.enablePagination',
path: 'footer.enablePagination',
- name: t('table.name-enable-paginations', 'Enable pagination'),
+ name: t('table.name-enable-pagination', 'Enable pagination'),
category,
editor: PaginationEditor,
});
diff --git a/public/app/plugins/panel/table/panelcfg.cue b/public/app/plugins/panel/table/panelcfg.cue
index 214e854f4a4..1bfbd053cf2 100644
--- a/public/app/plugins/panel/table/panelcfg.cue
+++ b/public/app/plugins/panel/table/panelcfg.cue
@@ -44,6 +44,10 @@ composableKinds: PanelCfg: {
}
// Controls the height of the rows
cellHeight?: ui.TableCellHeight & (*"sm" | _)
+ // Defines the number of columns to freeze on the left side of the table
+ frozenColumns?: {
+ left?: number | *0
+ }
} @cuetsy(kind="interface")
FieldConfig: {
ui.TableFieldOptions
diff --git a/public/app/plugins/panel/table/panelcfg.gen.ts b/public/app/plugins/panel/table/panelcfg.gen.ts
index 5b771ff2640..dba9153ea0c 100644
--- a/public/app/plugins/panel/table/panelcfg.gen.ts
+++ b/public/app/plugins/panel/table/panelcfg.gen.ts
@@ -23,6 +23,12 @@ export interface Options {
* Represents the index of the selected frame
*/
frameIndex: number;
+ /**
+ * Defines the number of columns to freeze on the left side of the table
+ */
+ frozenColumns?: {
+ left?: number;
+ };
/**
* Controls whether the panel should show the header
*/
diff --git a/public/app/plugins/panel/table/table-new/PaginationEditor.tsx b/public/app/plugins/panel/table/table-new/PaginationEditor.tsx
deleted file mode 100644
index ccd9451ee3f..00000000000
--- a/public/app/plugins/panel/table/table-new/PaginationEditor.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from 'react';
-
-import { StandardEditorProps } from '@grafana/data';
-import { selectors } from '@grafana/e2e-selectors';
-import { Switch } from '@grafana/ui';
-
-export function PaginationEditor({ onChange, value }: StandardEditorProps) {
- const changeValue = (event: React.FormEvent | undefined) => {
- onChange(event?.currentTarget.checked);
- };
-
- return (
-
- );
-}
diff --git a/public/app/plugins/panel/table/table-new/README.md b/public/app/plugins/panel/table/table-new/README.md
deleted file mode 100644
index 6f6656bfa29..00000000000
--- a/public/app/plugins/panel/table/table-new/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Table Panel - Native Plugin
-
-The Table Panel is **included** with Grafana.
-
-The table panel is very flexible, supporting both multiple modes for time series as well as for table, annotation and raw JSON data. It also provides date formatting and value formatting and coloring options.
-
-Check out the [Table Panel Showcase in the Grafana Playground](https://play.grafana.org/d/U_bZIMRMk/7-table-panel-showcase) or read more about it here:
-
-[https://grafana.com/docs/grafana/latest/features/panels/table_panel/](https://grafana.com/docs/grafana/latest/features/panels/table_panel/)
diff --git a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx b/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
deleted file mode 100644
index b70723695cb..00000000000
--- a/public/app/plugins/panel/table/table-new/TableCellOptionEditor.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { css } from '@emotion/css';
-import { merge } from 'lodash';
-import { useState } from 'react';
-
-import { GrafanaTheme2 } from '@grafana/data';
-import { t } from '@grafana/i18n';
-import { TableCellOptions, TableWrapTextOptions } from '@grafana/schema';
-import { Combobox, ComboboxOption, Field, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
-
-import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
-import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
-import { ImageCellOptionsEditor } from './cells/ImageCellOptionsEditor';
-import { MarkdownCellOptionsEditor } from './cells/MarkdownCellOptionsEditor';
-import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
-import { TextWrapOptionsEditor } from './cells/TextWrapOptionsEditor';
-
-// The props that any cell type editor are expected
-// to handle. In this case the generic type should
-// be a discriminated interface of TableCellOptions
-export interface TableCellEditorProps {
- cellOptions: T;
- onChange: (value: T) => void;
-}
-
-interface Props {
- value: TableCellOptions;
- onChange: (v: TableCellOptions) => void;
-}
-
-const TEXT_WRAP_CELL_TYPES = new Set([
- TableCellDisplayMode.Auto,
- TableCellDisplayMode.Sparkline,
- TableCellDisplayMode.ColorText,
- TableCellDisplayMode.ColorBackground,
- TableCellDisplayMode.DataLinks,
- TableCellDisplayMode.Pill,
-]);
-
-function isTextWrapCellType(value: TableCellOptions): value is TableCellOptions & TableWrapTextOptions {
- return TEXT_WRAP_CELL_TYPES.has(value.type);
-}
-
-export const TableCellOptionEditor = ({ value, onChange }: Props) => {
- const cellType = value.type;
- const styles = useStyles2(getStyles);
- const cellDisplayModeOptions: Array> = [
- { value: TableCellDisplayMode.Auto, label: t('table.cell-types.auto', 'Auto') },
- { value: TableCellDisplayMode.ColorText, label: t('table.cell-types.color-text', 'Colored text') },
- {
- value: TableCellDisplayMode.ColorBackground,
- label: t('table.cell-types.color-background', 'Colored background'),
- },
- { value: TableCellDisplayMode.DataLinks, label: t('table.cell-types.data-links', 'Data links') },
- { value: TableCellDisplayMode.Gauge, label: t('table.cell-types.gauge', 'Gauge') },
- { value: TableCellDisplayMode.Sparkline, label: t('table.cell-types.sparkline', 'Sparkline') },
- { value: TableCellDisplayMode.JSONView, label: t('table.cell-types.json', 'JSON View') },
- { value: TableCellDisplayMode.Pill, label: t('table.cell-types.pill', 'Pill') },
- { value: TableCellDisplayMode.Markdown, label: t('table.cell-types.markdown', 'Markdown + HTML') },
- { value: TableCellDisplayMode.Image, label: t('table.cell-types.image', 'Image') },
- { value: TableCellDisplayMode.Actions, label: t('table.cell-types.actions', 'Actions') },
- ];
- const currentMode = cellDisplayModeOptions.find((o) => o.value === cellType)!;
-
- let [settingCache, setSettingCache] = useState>({});
-
- // Update display mode on change
- const onCellTypeChange = (v: ComboboxOption) => {
- if (v !== null) {
- // Set the new type of cell starting
- // with default settings
- value = { type: v.value };
-
- // When changing cell type see if there were previously stored
- // settings and merge those with the changed value
- if (settingCache[value.type] !== undefined && Object.keys(settingCache[value.type]).length > 1) {
- value = merge({}, value, settingCache[value.type]);
- }
-
- onChange(value);
- }
- };
-
- // When options for a cell change we merge
- // any option changes with our options object
- const onCellOptionsChange = (options: TableCellOptions) => {
- settingCache[value.type] = merge({}, value, options);
- setSettingCache(settingCache);
- onChange(settingCache[value.type]);
- };
-
- // Setup and inject editor
- return (
-
-
-
-
- {isTextWrapCellType(value) && }
- {cellType === TableCellDisplayMode.Gauge && (
-
- )}
- {cellType === TableCellDisplayMode.ColorBackground && (
-
- )}
- {cellType === TableCellDisplayMode.Sparkline && (
-
- )}
- {cellType === TableCellDisplayMode.Image && (
-
- )}
- {cellType === TableCellDisplayMode.Markdown && (
-
- )}
-
- );
-};
-
-const getStyles = (theme: GrafanaTheme2) => ({
- fixBottomMargin: css({
- position: 'relative',
- marginBottom: theme.spacing(-2),
- }),
-});
diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx
deleted file mode 100644
index d656041bd33..00000000000
--- a/public/app/plugins/panel/table/table-new/TablePanel.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { css } from '@emotion/css';
-import { useCallback, useMemo } from 'react';
-
-import {
- ActionModel,
- DashboardCursorSync,
- DataFrame,
- FieldMatcherID,
- getFrameDisplayName,
- InterpolateFunction,
- PanelProps,
- SelectableValue,
- Field,
- cacheFieldDisplayNames,
-} from '@grafana/data';
-import { config, PanelDataErrorView } from '@grafana/runtime';
-import { Select, usePanelContext, useTheme2 } from '@grafana/ui';
-import { TableSortByFieldState } from '@grafana/ui/internal';
-import { TableNG } from '@grafana/ui/unstable';
-import { getConfig } from 'app/core/config';
-
-import { getActions } from '../../../../features/actions/utils';
-
-import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations';
-import { Options } from './panelcfg.gen';
-
-interface Props extends PanelProps {}
-
-export function TablePanel(props: Props) {
- const {
- data,
- height,
- width,
- options,
- onFieldConfigChange,
- onOptionsChange,
- fieldConfig,
- id,
- timeRange,
- replaceVariables,
- transparent,
- } = props;
-
- useMemo(() => {
- cacheFieldDisplayNames(data.series);
- }, [data.series]);
-
- const theme = useTheme2();
- const panelContext = usePanelContext();
- const _getActions = useCallback(
- (frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables),
- [replaceVariables]
- );
- const frames = hasDeprecatedParentRowIndex(data.series)
- ? migrateFromParentRowIndexToNestedFrames(data.series)
- : data.series;
- const count = frames?.length;
- const hasFields = frames.some((frame) => frame.fields.length > 0);
- const currentIndex = getCurrentFrameIndex(frames, options);
- const main = frames[currentIndex];
-
- const onColumnResize = useCallback(
- (fieldDisplayName: string, width: number) => {
- const { overrides } = fieldConfig;
-
- const matcherId = FieldMatcherID.byName;
- const propId = 'custom.width';
-
- // look for existing override
- const override = overrides.find((o) => o.matcher.id === matcherId && o.matcher.options === fieldDisplayName);
-
- if (override) {
- // look for existing property
- const property = override.properties.find((prop) => prop.id === propId);
- if (property) {
- property.value = width;
- } else {
- override.properties.push({ id: propId, value: width });
- }
- } else {
- overrides.push({
- matcher: { id: matcherId, options: fieldDisplayName },
- properties: [{ id: propId, value: width }],
- });
- }
-
- onFieldConfigChange({
- ...fieldConfig,
- overrides,
- });
- },
- [fieldConfig, onFieldConfigChange]
- );
-
- const onSortByChange = useCallback(
- (sortBy: TableSortByFieldState[]) => {
- onOptionsChange({
- ...options,
- sortBy,
- });
- },
- [options, onOptionsChange]
- );
-
- const onChangeTableSelection = useCallback(
- (val: SelectableValue) => {
- onOptionsChange({
- ...options,
- frameIndex: val.value || 0,
- });
- },
- [options, onOptionsChange]
- );
-
- let tableHeight = height;
-
- if (!count || !hasFields) {
- return ;
- }
-
- if (count > 1) {
- const inputHeight = theme.spacing.gridSize * theme.components.height.md;
- const padding = theme.spacing.gridSize;
-
- tableHeight = height - inputHeight - padding;
- }
-
- const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
-
- const disableSanitizeHtml = getConfig().disableSanitizeHtml;
-
- const tableElement = (
-
- );
-
- if (count === 1) {
- return tableElement;
- }
-
- const names = frames.map((frame, index) => {
- return {
- label: getFrameDisplayName(frame),
- value: index,
- };
- });
-
- return (
-
- );
-}
-
-function getCurrentFrameIndex(frames: DataFrame[], options: Options) {
- return options.frameIndex > 0 && options.frameIndex < frames.length ? options.frameIndex : 0;
-}
-
-// placeholder function; assuming the values are already interpolated
-const replaceVars: InterpolateFunction = (value: string) => value;
-
-const getCellActions = (
- dataFrame: DataFrame,
- field: Field,
- rowIndex: number,
- replaceVariables: InterpolateFunction | undefined
-): Array> => {
- const numActions = field.config.actions?.length ?? 0;
-
- if (numActions > 0) {
- const actions = getActions(
- dataFrame,
- field,
- field.state!.scopedVars!,
- replaceVariables ?? replaceVars,
- field.config.actions ?? [],
- { valueRowIndex: rowIndex }
- );
-
- if (actions.length === 1) {
- return actions;
- } else {
- const actionsOut: Array> = [];
- const actionLookup = new Set();
-
- actions.forEach((action) => {
- const key = action.title;
-
- if (!actionLookup.has(key)) {
- actionsOut.push(action);
- actionLookup.add(key);
- }
- });
-
- return actionsOut;
- }
- }
-
- return [];
-};
-
-const tableStyles = {
- wrapper: css({
- display: 'flex',
- flexDirection: 'column',
- justifyContent: 'space-between',
- height: '100%',
- }),
- selectWrapper: css({
- padding: '8px 8px 0px 8px',
- }),
-};
diff --git a/public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap b/public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap
deleted file mode 100644
index e51e742e7d5..00000000000
--- a/public/app/plugins/panel/table/table-new/__snapshots__/migrations.test.ts.snap
+++ /dev/null
@@ -1,82 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Table Migrations migrates transform out to core transforms 1`] = `
-{
- "fieldConfig": {
- "defaults": {
- "custom": {},
- },
- "overrides": [],
- },
- "transformations": [
- {
- "id": "seriesToColumns",
- "options": {
- "reducers": [],
- },
- },
- ],
-}
-`;
-
-exports[`Table Migrations migrates transform out to core transforms 2`] = `
-{
- "fieldConfig": {
- "defaults": {
- "custom": {},
- },
- "overrides": [],
- },
- "transformations": [
- {
- "id": "seriesToRows",
- "options": {
- "reducers": [],
- },
- },
- ],
-}
-`;
-
-exports[`Table Migrations migrates transform out to core transforms 3`] = `
-{
- "fieldConfig": {
- "defaults": {
- "custom": {},
- },
- "overrides": [],
- },
- "transformations": [
- {
- "id": "reduce",
- "options": {
- "includeTimeField": false,
- "reducers": [
- "mean",
- "max",
- "lastNotNull",
- ],
- },
- },
- ],
-}
-`;
-
-exports[`Table Migrations migrates transform out to core transforms 4`] = `
-{
- "fieldConfig": {
- "defaults": {
- "custom": {},
- },
- "overrides": [],
- },
- "transformations": [
- {
- "id": "merge",
- "options": {
- "reducers": [],
- },
- },
- ],
-}
-`;
diff --git a/public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx
deleted file mode 100644
index 4e29eaf9360..00000000000
--- a/public/app/plugins/panel/table/table-new/cells/BarGaugeCellOptionsEditor.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { SelectableValue } from '@grafana/data';
-import { t } from '@grafana/i18n';
-import { BarGaugeDisplayMode, BarGaugeValueMode, TableBarGaugeCellOptions } from '@grafana/schema';
-import { Field, RadioButtonGroup, Stack } from '@grafana/ui';
-
-import { TableCellEditorProps } from '../TableCellOptionEditor';
-
-type Props = TableCellEditorProps;
-
-export function BarGaugeCellOptionsEditor({ cellOptions, onChange }: Props) {
- // Set the display mode on change
-
- const onCellOptionsChange = (v: BarGaugeDisplayMode) => {
- cellOptions.mode = v;
- onChange(cellOptions);
- };
-
- const onValueModeChange = (v: BarGaugeValueMode) => {
- cellOptions.valueDisplayMode = v;
- onChange(cellOptions);
- };
-
- return (
-
-
-
-
-
-
-
-
- );
-}
-
-const barGaugeOpts: SelectableValue[] = [
- { value: BarGaugeDisplayMode.Basic, label: 'Basic' },
- { value: BarGaugeDisplayMode.Gradient, label: 'Gradient' },
- { value: BarGaugeDisplayMode.Lcd, label: 'Retro LCD' },
-];
-
-const valueModes: SelectableValue[] = [
- { value: BarGaugeValueMode.Color, label: 'Value color' },
- { value: BarGaugeValueMode.Text, label: 'Text color' },
- { value: BarGaugeValueMode.Hidden, label: 'Hidden' },
-];
diff --git a/public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx
deleted file mode 100644
index 33d85539ec4..00000000000
--- a/public/app/plugins/panel/table/table-new/cells/ColorBackgroundCellOptionsEditor.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { SelectableValue } from '@grafana/data';
-import { selectors } from '@grafana/e2e-selectors';
-import { t } from '@grafana/i18n';
-import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
-import { Field, RadioButtonGroup, Switch } from '@grafana/ui';
-
-import { TableCellEditorProps } from '../TableCellOptionEditor';
-
-import { TextWrapOptionsEditor } from './TextWrapOptionsEditor';
-
-const colorBackgroundOpts: Array> = [
- { value: TableCellBackgroundDisplayMode.Basic, label: 'Basic' },
- { value: TableCellBackgroundDisplayMode.Gradient, label: 'Gradient' },
-];
-export const ColorBackgroundCellOptionsEditor = ({
- cellOptions,
- onChange,
-}: TableCellEditorProps) => {
- // Set the display mode on change
- const onCellOptionsChange = (v: TableCellBackgroundDisplayMode) => {
- cellOptions.mode = v;
- onChange(cellOptions);
- };
- const onColorRowChange = () => {
- cellOptions.applyToRow = !cellOptions.applyToRow;
- onChange(cellOptions);
- };
-
- return (
- <>
-
-
-
-
-
-
-
-
- {
- cellOptions.wrapText = updatedCellOptions.wrapText;
- onChange(cellOptions);
- }}
- />
- >
- );
-};
diff --git a/public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx
deleted file mode 100644
index 75233f4fefe..00000000000
--- a/public/app/plugins/panel/table/table-new/cells/ImageCellOptionsEditor.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { FormEvent } from 'react';
-
-import { t } from '@grafana/i18n';
-import { TableImageCellOptions } from '@grafana/schema';
-import { Field, Input } from '@grafana/ui';
-
-import { TableCellEditorProps } from '../TableCellOptionEditor';
-
-export const ImageCellOptionsEditor = ({ cellOptions, onChange }: TableCellEditorProps) => {
- const onAltChange = (e: FormEvent) => {
- cellOptions.alt = e.currentTarget.value;
- onChange(cellOptions);
- };
-
- const onTitleChange = (e: FormEvent) => {
- cellOptions.title = e.currentTarget.value;
- onChange(cellOptions);
- };
-
- return (
- <>
-
-
-
-
-
-
-
- >
- );
-};
diff --git a/public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx b/public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx
deleted file mode 100644
index 69ad1c0f0b5..00000000000
--- a/public/app/plugins/panel/table/table-new/cells/SparklineCellOptionsEditor.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { css } from '@emotion/css';
-import { useMemo } from 'react';
-
-import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data';
-import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
-import { Field, useStyles2, Stack } from '@grafana/ui';
-import { defaultSparklineCellConfig } from '@grafana/ui/internal';
-
-import { getGraphFieldConfig } from '../../../timeseries/config';
-import { TableCellEditorProps } from '../TableCellOptionEditor';
-
-type OptionKey = keyof TableSparklineCellOptions;
-
-const optionIds: Array = [
- 'hideValue',
- 'drawStyle',
- 'lineInterpolation',
- 'barAlignment',
- 'lineWidth',
- 'fillOpacity',
- 'gradientMode',
- 'lineStyle',
- 'spanNulls',
- 'showPoints',
- 'pointSize',
-];
-
-function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs {
- const graphFieldConfig = getGraphFieldConfig(cfg);
- return {
- ...graphFieldConfig,
- useCustomConfig: (builder) => {
- graphFieldConfig.useCustomConfig?.(builder);
- builder.addBooleanSwitch({
- path: 'hideValue',
- name: 'Hide value',
- });
- },
- };
-}
-
-export const SparklineCellOptionsEditor = (props: TableCellEditorProps) => {
- const { cellOptions, onChange } = props;
-
- const registry = useMemo(() => {
- const config = getChartCellConfig(defaultSparklineCellConfig);
- return createFieldConfigRegistry(config, 'ChartCell');
- }, []);
-
- const style = useStyles2(getStyles);
-
- const values = { ...defaultSparklineCellConfig, ...cellOptions };
-
- return (
-
- {registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => {
- if (item.showIf && !item.showIf(values)) {
- return null;
- }
- const Editor = item.editor;
- const path = item.path;
-
- return (
-
- onChange({ ...cellOptions, [path]: val })}
- value={(isOptionKey(path, values) ? values[path] : undefined) ?? item.defaultValue}
- item={item}
- context={{ data: [] }}
- />
-
- );
- })}
-
- );
-};
-
-// jumping through hoops to avoid using "any"
-function isOptionKey(key: string, options: TableSparklineCellOptions): key is OptionKey {
- return key in options;
-}
-
-const getStyles = () => ({
- field: css({
- width: '100%',
-
- // @TODO don't show "scheme" option for custom gradient mode.
- // it needs thresholds to work, which are not supported
- // for area chart cell right now
- "[title='Use color scheme to define gradient']": {
- display: 'none',
- },
- }),
-});
diff --git a/public/app/plugins/panel/table/table-new/img/icn-table-panel.svg b/public/app/plugins/panel/table/table-new/img/icn-table-panel.svg
deleted file mode 100644
index 21846a8d38f..00000000000
--- a/public/app/plugins/panel/table/table-new/img/icn-table-panel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/app/plugins/panel/table/table-new/migrations.test.ts b/public/app/plugins/panel/table/table-new/migrations.test.ts
deleted file mode 100644
index a40120c8857..00000000000
--- a/public/app/plugins/panel/table/table-new/migrations.test.ts
+++ /dev/null
@@ -1,364 +0,0 @@
-import { createDataFrame, FieldType, PanelModel } from '@grafana/data';
-
-import { migrateFromParentRowIndexToNestedFrames, tablePanelChangedHandler } from './migrations';
-
-describe('Table Migrations', () => {
- it('migrates transform out to core transforms', () => {
- const toColumns = {
- angular: {
- columns: [],
- styles: [],
- transform: 'timeseries_to_columns',
- options: {},
- },
- };
- const toRows = {
- angular: {
- columns: [],
- styles: [],
- transform: 'timeseries_to_rows',
- options: {},
- },
- };
- const aggregations = {
- angular: {
- columns: [
- {
- text: 'Avg',
- value: 'avg',
- $$hashKey: 'object:82',
- },
- {
- text: 'Max',
- value: 'max',
- $$hashKey: 'object:83',
- },
- {
- text: 'Current',
- value: 'current',
- $$hashKey: 'object:84',
- },
- ],
- styles: [],
- transform: 'timeseries_aggregations',
- options: {},
- },
- };
- const table = {
- angular: {
- columns: [],
- styles: [],
- transform: 'table',
- options: {},
- },
- };
-
- const columnsPanel = {} as PanelModel;
- tablePanelChangedHandler(columnsPanel, 'table-old', toColumns);
- expect(columnsPanel).toMatchSnapshot();
- const rowsPanel = {} as PanelModel;
- tablePanelChangedHandler(rowsPanel, 'table-old', toRows);
- expect(rowsPanel).toMatchSnapshot();
- const aggregationsPanel = {} as PanelModel;
- tablePanelChangedHandler(aggregationsPanel, 'table-old', aggregations);
- expect(aggregationsPanel).toMatchSnapshot();
- const tablePanel = {} as PanelModel;
- tablePanelChangedHandler(tablePanel, 'table-old', table);
- expect(tablePanel).toMatchSnapshot();
- });
-
- it('migrates styles to field config overrides and defaults', () => {
- const oldStyles = {
- angular: {
- columns: [],
- styles: [
- {
- alias: 'Time',
- align: 'auto',
- dateFormat: 'YYYY-MM-DD HH:mm:ss',
- pattern: 'Time',
- type: 'date',
- $$hashKey: 'object:195',
- },
- {
- alias: '',
- align: 'left',
- colorMode: 'cell',
- colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
- dateFormat: 'YYYY-MM-DD HH:mm:ss',
- decimals: 2,
- mappingType: 1,
- pattern: 'ColorCell',
- thresholds: ['5', '10'],
- type: 'number',
- unit: 'currencyUSD',
- $$hashKey: 'object:196',
- },
- {
- alias: '',
- align: 'auto',
- colorMode: 'value',
- colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
- dateFormat: 'YYYY-MM-DD HH:mm:ss',
- decimals: 2,
- link: true,
- linkTargetBlank: true,
- linkTooltip: '',
- linkUrl: 'http://www.grafana.com',
- mappingType: 1,
- pattern: 'ColorValue',
- thresholds: ['5', '10'],
- type: 'number',
- unit: 'Bps',
- $$hashKey: 'object:197',
- },
- {
- unit: 'short',
- type: 'number',
- alias: '',
- decimals: 2,
- colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
- colorMode: null,
- pattern: '/.*/',
- thresholds: [],
- align: 'right',
- },
- ],
- },
- };
-
- const panel = {} as PanelModel;
- tablePanelChangedHandler(panel, 'table-old', oldStyles);
- expect(panel).toMatchInlineSnapshot(`
- {
- "fieldConfig": {
- "defaults": {
- "custom": {
- "align": "right",
- },
- "decimals": 2,
- "displayName": "",
- "unit": "short",
- },
- "overrides": [
- {
- "matcher": {
- "id": "byName",
- "options": "Time",
- },
- "properties": [
- {
- "id": "displayName",
- "value": "Time",
- },
- {
- "id": "unit",
- "value": "time: YYYY-MM-DD HH:mm:ss",
- },
- {
- "id": "custom.align",
- "value": null,
- },
- ],
- },
- {
- "matcher": {
- "id": "byName",
- "options": "ColorCell",
- },
- "properties": [
- {
- "id": "unit",
- "value": "currencyUSD",
- },
- {
- "id": "decimals",
- "value": 2,
- },
- {
- "id": "custom.cellOptions",
- "value": {
- "type": "color-background",
- },
- },
- {
- "id": "custom.align",
- "value": "left",
- },
- {
- "id": "thresholds",
- "value": {
- "mode": "absolute",
- "steps": [
- {
- "color": "rgba(245, 54, 54, 0.9)",
- "value": -Infinity,
- },
- {
- "color": "rgba(237, 129, 40, 0.89)",
- "value": 5,
- },
- {
- "color": "rgba(50, 172, 45, 0.97)",
- "value": 10,
- },
- ],
- },
- },
- ],
- },
- {
- "matcher": {
- "id": "byName",
- "options": "ColorValue",
- },
- "properties": [
- {
- "id": "unit",
- "value": "Bps",
- },
- {
- "id": "decimals",
- "value": 2,
- },
- {
- "id": "links",
- "value": [
- {
- "targetBlank": true,
- "title": "",
- "url": "http://www.grafana.com",
- },
- ],
- },
- {
- "id": "custom.cellOptions",
- "value": {
- "type": "color-text",
- },
- },
- {
- "id": "custom.align",
- "value": null,
- },
- {
- "id": "thresholds",
- "value": {
- "mode": "absolute",
- "steps": [
- {
- "color": "rgba(245, 54, 54, 0.9)",
- "value": -Infinity,
- },
- {
- "color": "rgba(237, 129, 40, 0.89)",
- "value": 5,
- },
- {
- "color": "rgba(50, 172, 45, 0.97)",
- "value": 10,
- },
- ],
- },
- },
- ],
- },
- ],
- },
- "transformations": [],
- }
- `);
- });
-
- it('migrates hidden fields to override', () => {
- const oldStyles = {
- angular: {
- columns: [],
- styles: [
- {
- dateFormat: 'YYYY-MM-DD HH:mm:ss',
- pattern: 'time',
- type: 'hidden',
- },
- ],
- },
- };
-
- const panel = {} as PanelModel;
- tablePanelChangedHandler(panel, 'table-old', oldStyles);
- expect(panel.fieldConfig.overrides).toEqual([
- {
- matcher: {
- id: 'byName',
- options: 'time',
- },
- properties: [
- {
- id: 'custom.hidden',
- value: true,
- },
- ],
- },
- ]);
- });
-
- it('migrates DataFrame[] from format using meta.custom.parentRowIndex to format using FieldType.nestedFrames', () => {
- const mainFrame = (refId: string) => {
- return createDataFrame({
- refId,
- fields: [
- {
- name: 'field',
- type: FieldType.string,
- config: {},
- values: ['a', 'b', 'c'],
- },
- ],
- meta: {
- preferredVisualisationType: 'table',
- },
- });
- };
-
- const subFrame = (index: number) => {
- return createDataFrame({
- refId: 'B',
- fields: [
- {
- name: `field_${index}`,
- type: FieldType.string,
- config: {},
- values: [`${index}_subA`, 'subB', 'subC'],
- },
- ],
- meta: {
- preferredVisualisationType: 'table',
- custom: {
- parentRowIndex: index,
- },
- },
- });
- };
-
- const oldFormat = [mainFrame('A'), mainFrame('B'), subFrame(0), subFrame(1)];
- const newFormat = migrateFromParentRowIndexToNestedFrames(oldFormat);
- expect(newFormat.length).toBe(2);
- expect(newFormat[0].refId).toBe('A');
- expect(newFormat[1].refId).toBe('B');
- expect(newFormat[0].fields.length).toBe(1);
- expect(newFormat[1].fields.length).toBe(2);
- expect(newFormat[0].fields[0].name).toBe('field');
- expect(newFormat[1].fields[0].name).toBe('field');
- expect(newFormat[1].fields[1].name).toBe('nested');
- expect(newFormat[1].fields[1].type).toBe(FieldType.nestedFrames);
- expect(newFormat[1].fields[1].values.length).toBe(2);
- expect(newFormat[1].fields[1].values[0][0].refId).toBe('B');
- expect(newFormat[1].fields[1].values[1][0].refId).toBe('B');
- expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
- expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
- expect(newFormat[1].fields[1].values[0][0].fields[0].name).toBe('field_0');
- expect(newFormat[1].fields[1].values[1][0].fields[0].name).toBe('field_1');
- expect(newFormat[1].fields[1].values[0][0].fields[0].values[0]).toBe('0_subA');
- expect(newFormat[1].fields[1].values[1][0].fields[0].values[0]).toBe('1_subA');
- });
-});
diff --git a/public/app/plugins/panel/table/table-new/migrations.ts b/public/app/plugins/panel/table/table-new/migrations.ts
deleted file mode 100644
index aa4c635682c..00000000000
--- a/public/app/plugins/panel/table/table-new/migrations.ts
+++ /dev/null
@@ -1,299 +0,0 @@
-import { omitBy, isNil, isNumber, defaultTo, groupBy } from 'lodash';
-
-import {
- PanelModel,
- FieldMatcherID,
- ConfigOverrideRule,
- ThresholdsMode,
- ThresholdsConfig,
- FieldConfig,
- DataFrame,
- FieldType,
-} from '@grafana/data';
-import { ReduceTransformerOptions } from '@grafana/data/internal';
-
-import { Options } from './panelcfg.gen';
-
-/**
- * At 7.0, the `table` panel was swapped from an angular implementation to a react one.
- * The models do not match, so this process will delegate to the old implementation when
- * a saved table configuration exists.
- */
-export const tableMigrationHandler = (panel: PanelModel): Partial => {
- // Table was saved as an angular table, lets just swap to the 'table-old' panel
- if (!panel.pluginVersion && 'columns' in panel) {
- console.log('Was angular table', panel);
- }
-
- // Nothing changed
- return panel.options;
-};
-
-const transformsMap = {
- timeseries_to_rows: 'seriesToRows',
- timeseries_to_columns: 'seriesToColumns',
- timeseries_aggregations: 'reduce',
- table: 'merge',
-};
-
-const columnsMap = {
- avg: 'mean',
- min: 'min',
- max: 'max',
- total: 'sum',
- current: 'lastNotNull',
- count: 'count',
-};
-
-const colorModeMap = {
- cell: 'color-background',
- row: 'color-background',
- value: 'color-text',
-};
-
-type Transformations = keyof typeof transformsMap;
-
-type Transformation = {
- id: string;
- options: ReduceTransformerOptions;
-};
-
-type Columns = keyof typeof columnsMap;
-
-type Column = {
- value: Columns;
- text: string;
-};
-
-type ColorModes = keyof typeof colorModeMap;
-
-const generateThresholds = (thresholds: string[], colors: string[]) => {
- return [-Infinity, ...thresholds].map((threshold, idx) => ({
- color: colors[idx],
- value: isNumber(threshold) ? threshold : parseInt(threshold, 10),
- }));
-};
-
-const migrateTransformations = (
- panel: PanelModel>,
- oldOpts: { columns: any; transform: Transformations }
-) => {
- const transformations: Transformation[] = panel.transformations ?? [];
- if (Object.keys(transformsMap).includes(oldOpts.transform)) {
- const opts: ReduceTransformerOptions = {
- reducers: [],
- };
- if (oldOpts.transform === 'timeseries_aggregations') {
- opts.includeTimeField = false;
- opts.reducers = oldOpts.columns.map((column: Column) => columnsMap[column.value]);
- }
- transformations.push({
- id: transformsMap[oldOpts.transform],
- options: opts,
- });
- }
- return transformations;
-};
-
-type Style = {
- unit: string;
- type: string;
- alias: string;
- decimals: number;
- colors: string[];
- colorMode: ColorModes;
- pattern: string;
- thresholds: string[];
- align?: string;
- dateFormat: string;
- link: boolean;
- linkTargetBlank?: boolean;
- linkTooltip?: string;
- linkUrl?: string;
-};
-
-const migrateTableStyleToOverride = (style: Style) => {
- const fieldMatcherId = /^\/.*\/$/.test(style.pattern) ? FieldMatcherID.byRegexp : FieldMatcherID.byName;
- const override: ConfigOverrideRule = {
- matcher: {
- id: fieldMatcherId,
- options: style.pattern,
- },
- properties: [],
- };
-
- if (style.alias) {
- override.properties.push({
- id: 'displayName',
- value: style.alias,
- });
- }
-
- if (style.unit) {
- override.properties.push({
- id: 'unit',
- value: style.unit,
- });
- }
-
- if (style.decimals) {
- override.properties.push({
- id: 'decimals',
- value: style.decimals,
- });
- }
-
- if (style.type === 'date') {
- override.properties.push({
- id: 'unit',
- value: `time: ${style.dateFormat}`,
- });
- }
-
- if (style.type === 'hidden') {
- override.properties.push({
- id: 'custom.hidden',
- value: true,
- });
- }
-
- if (style.link) {
- override.properties.push({
- id: 'links',
- value: [
- {
- title: defaultTo(style.linkTooltip, ''),
- url: defaultTo(style.linkUrl, ''),
- targetBlank: defaultTo(style.linkTargetBlank, false),
- },
- ],
- });
- }
-
- if (style.colorMode) {
- override.properties.push({
- id: 'custom.cellOptions',
- value: {
- type: colorModeMap[style.colorMode],
- },
- });
- }
-
- if (style.align) {
- override.properties.push({
- id: 'custom.align',
- value: style.align === 'auto' ? null : style.align,
- });
- }
-
- if (style.thresholds?.length) {
- override.properties.push({
- id: 'thresholds',
- value: {
- mode: ThresholdsMode.Absolute,
- steps: generateThresholds(style.thresholds, style.colors),
- },
- });
- }
-
- return override;
-};
-
-const migrateDefaults = (prevDefaults: Style) => {
- let defaults: FieldConfig = {
- custom: {},
- };
- if (prevDefaults) {
- defaults = omitBy(
- {
- unit: prevDefaults.unit,
- decimals: prevDefaults.decimals,
- displayName: prevDefaults.alias,
- custom: {
- align: prevDefaults.align === 'auto' ? null : prevDefaults.align,
- },
- },
- isNil
- );
-
- if (prevDefaults.thresholds.length) {
- const thresholds: ThresholdsConfig = {
- mode: ThresholdsMode.Absolute,
- steps: generateThresholds(prevDefaults.thresholds, prevDefaults.colors),
- };
- defaults.thresholds = thresholds;
- }
-
- if (prevDefaults.colorMode) {
- defaults.custom.cellOptions = {
- type: colorModeMap[prevDefaults.colorMode],
- };
- }
- }
- return defaults;
-};
-
-/**
- * This is called when the panel changes from another panel
- */
-export const tablePanelChangedHandler = (
- panel: PanelModel>,
- prevPluginId: string,
- prevOptions: any
-) => {
- // Changing from angular table panel
- if (prevPluginId === 'table-old' && prevOptions.angular) {
- const oldOpts = prevOptions.angular;
- const transformations = migrateTransformations(panel, oldOpts);
- const prevDefaults = oldOpts.styles.find((style: any) => style.pattern === '/.*/');
- const defaults = migrateDefaults(prevDefaults);
- const overrides = oldOpts.styles.filter((style: any) => style.pattern !== '/.*/').map(migrateTableStyleToOverride);
-
- panel.transformations = transformations;
- panel.fieldConfig = {
- defaults,
- overrides,
- };
- }
-
- return {};
-};
-
-const getMainFrames = (frames: DataFrame[] | null) => {
- return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]];
-};
-
-/**
- * In 9.3 meta.custom.parentRowIndex was introduced to support sub-tables.
- * In 10.2 meta.custom.parentRowIndex was deprecated in favor of FieldType.nestedFrames, which supports multiple nested frames.
- * Migrate DataFrame[] from using meta.custom.parentRowIndex to using FieldType.nestedFrames
- */
-export const migrateFromParentRowIndexToNestedFrames = (frames: DataFrame[] | null) => {
- const migratedFrames: DataFrame[] = [];
- const mainFrames = getMainFrames(frames).filter(
- (frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
- );
-
- mainFrames?.forEach((frame) => {
- const subFrames = frames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined);
- const subFramesGrouped = groupBy(subFrames, (frame: DataFrame) => frame.meta?.custom?.parentRowIndex);
- const subFramesByIndex = Object.keys(subFramesGrouped).map((key) => subFramesGrouped[key]);
- const migratedFrame = { ...frame };
-
- if (subFrames && subFrames.length > 0) {
- migratedFrame.fields.push({
- name: 'nested',
- type: FieldType.nestedFrames,
- config: {},
- values: subFramesByIndex,
- });
- }
- migratedFrames.push(migratedFrame);
- });
-
- return migratedFrames;
-};
-
-export const hasDeprecatedParentRowIndex = (frames: DataFrame[] | null) => {
- return frames?.some((df) => df.meta?.custom?.parentRowIndex !== undefined);
-};
diff --git a/public/app/plugins/panel/table/table-new/module.tsx b/public/app/plugins/panel/table/table-new/module.tsx
deleted file mode 100644
index 0e17f6c1072..00000000000
--- a/public/app/plugins/panel/table/table-new/module.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-import {
- FieldOverrideContext,
- FieldType,
- getFieldDisplayName,
- PanelPlugin,
- ReducerID,
- standardEditorsRegistry,
- identityOverrideProcessor,
- FieldConfigProperty,
-} from '@grafana/data';
-import { t } from '@grafana/i18n';
-import {
- TableCellOptions,
- TableCellDisplayMode,
- defaultTableFieldOptions,
- TableCellHeight,
- TableCellTooltipPlacement,
-} from '@grafana/schema';
-
-import { PaginationEditor } from './PaginationEditor';
-import { TableCellOptionEditor } from './TableCellOptionEditor';
-import { TablePanel } from './TablePanel';
-import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
-import { Options, defaultOptions, FieldConfig } from './panelcfg.gen';
-import { TableSuggestionsSupplier } from './suggestions';
-
-export const plugin = new PanelPlugin(TablePanel)
- .setPanelChangeHandler(tablePanelChangedHandler)
- .setMigrationHandler(tableMigrationHandler)
- .useFieldConfig({
- standardOptions: {
- [FieldConfigProperty.Actions]: {
- hideFromDefaults: false,
- },
- },
- useCustomConfig: (builder) => {
- const category = [t('table-new.category-table', 'Table')];
- const cellCategory = [t('table-new.category-cell-options', 'Cell options')];
- builder
- .addNumberInput({
- path: 'minWidth',
- name: t('table-new.name-min-column-width', 'Minimum column width'),
- category,
- description: t('table-new.description-min-column-width', 'The minimum width for column auto resizing'),
- settings: {
- placeholder: '150',
- min: 50,
- max: 500,
- },
- shouldApply: () => true,
- defaultValue: defaultTableFieldOptions.minWidth,
- })
- .addNumberInput({
- path: 'width',
- name: t('table-new.name-column-width', 'Column width'),
- category,
- settings: {
- placeholder: t('table-new.placeholder-column-width', 'auto'),
- min: 20,
- },
- shouldApply: () => true,
- defaultValue: defaultTableFieldOptions.width,
- })
- .addRadio({
- path: 'align',
- name: t('table-new.name-column-alignment', 'Column alignment'),
- category,
- settings: {
- options: [
- { label: t('table-new.column-alignment-options.label-auto', 'Auto'), value: 'auto' },
- { label: t('table-new.column-alignment-options.label-left', 'Left'), value: 'left' },
- { label: t('table-new.column-alignment-options.label-center', 'Center'), value: 'center' },
- { label: t('table-new.column-alignment-options.label-right', 'Right'), value: 'right' },
- ],
- },
- defaultValue: defaultTableFieldOptions.align,
- })
- .addCustomEditor({
- id: 'cellOptions',
- path: 'cellOptions',
- name: t('table-new.name-cell-type', 'Cell type'),
- editor: TableCellOptionEditor,
- override: TableCellOptionEditor,
- defaultValue: defaultTableFieldOptions.cellOptions,
- process: identityOverrideProcessor,
- category: cellCategory,
- shouldApply: () => true,
- })
- .addBooleanSwitch({
- path: 'inspect',
- name: t('table-new.name-cell-value-inspect', 'Cell value inspect'),
- description: t('table-new.description-cell-value-inspect', 'Enable cell value inspection in a modal window'),
- defaultValue: false,
- category: cellCategory,
- showIf: (cfg) => {
- return (
- cfg.cellOptions.type === TableCellDisplayMode.Auto ||
- cfg.cellOptions.type === TableCellDisplayMode.JSONView ||
- cfg.cellOptions.type === TableCellDisplayMode.ColorText ||
- cfg.cellOptions.type === TableCellDisplayMode.ColorBackground
- );
- },
- })
- .addBooleanSwitch({
- path: 'filterable',
- name: t('table-new.name-column-filter', 'Column filter'),
- category,
- description: t('table-new.description-column-filter', 'Enables/disables field filters in table'),
- defaultValue: defaultTableFieldOptions.filterable,
- })
- .addBooleanSwitch({
- path: 'wrapHeaderText',
- name: t('table.name-wrap-header-text', 'Wrap header text'),
- description: t('table.description-wrap-header-text', 'Enables text wrapping for column headers'),
- category,
- defaultValue: defaultTableFieldOptions.wrapHeaderText,
- })
- .addBooleanSwitch({
- path: 'hidden',
- name: t('table-new.name-hide-in-table', 'Hide in table'),
- category,
- defaultValue: undefined,
- hideFromDefaults: true,
- })
- .addFieldNamePicker({
- path: 'tooltip.field',
- name: t('table-new.name-tooltip-from-field', 'Tooltip from field'),
- description: t(
- 'table-new.description-tooltip-from-field',
- 'Render a cell from a field (hidden or visible) in a tooltip'
- ),
- category: cellCategory,
- })
- .addSelect({
- path: 'tooltip.placement',
- name: t('table-new.name-tooltip-placement', 'Tooltip placement'),
- category: cellCategory,
- settings: {
- options: [
- {
- label: t('table-new.tooltip-placement-options.label-auto', 'Auto'),
- value: TableCellTooltipPlacement.Auto,
- },
- {
- label: t('table-new.tooltip-placement-options.label-top', 'Top'),
- value: TableCellTooltipPlacement.Top,
- },
- {
- label: t('table-new.tooltip-placement-options.label-right', 'Right'),
- value: TableCellTooltipPlacement.Right,
- },
- {
- label: t('table-new.tooltip-placement-options.label-bottom', 'Bottom'),
- value: TableCellTooltipPlacement.Bottom,
- },
- {
- label: t('table-new.tooltip-placement-options.label-left', 'Left'),
- value: TableCellTooltipPlacement.Left,
- },
- ],
- },
- defaultValue: 'auto',
- showIf: (cfg) => cfg.tooltip?.field !== undefined,
- });
- },
- })
- .setPanelOptions((builder) => {
- const footerCategory = [t('table-new.category-table-footer', 'Table footer')];
- const category = [t('table-new.category-table', 'Table')];
- builder
- .addBooleanSwitch({
- path: 'showHeader',
- name: t('table-new.name-show-table-header', 'Show table header'),
- category,
- defaultValue: defaultOptions.showHeader,
- })
- .addNumberInput({
- path: 'frozenColumns.left',
- name: t('table-new.name-frozen-columns', 'Frozen columns'),
- description: t('table-new.description-frozen-columns', 'Columns are frozen from the left side of the table'),
- settings: {
- placeholder: 'none',
- },
- category,
- })
- .addRadio({
- path: 'cellHeight',
- name: t('table-new.name-cell-height', 'Cell height'),
- category,
- defaultValue: defaultOptions.cellHeight,
- settings: {
- options: [
- { value: TableCellHeight.Sm, label: t('table-new.cell-height-options.label-small', 'Small') },
- { value: TableCellHeight.Md, label: t('table-new.cell-height-options.label-medium', 'Medium') },
- { value: TableCellHeight.Lg, label: t('table-new.cell-height-options.label-large', 'Large') },
- ],
- },
- })
- .addBooleanSwitch({
- path: 'footer.show',
- category: footerCategory,
- name: t('table-new.name-show-table-footer', 'Show table footer'),
- defaultValue: defaultOptions.footer?.show,
- })
- .addCustomEditor({
- id: 'footer.reducer',
- category: footerCategory,
- path: 'footer.reducer',
- name: t('table-new.name-calculation', 'Calculation'),
- description: t('table-new.description-calculation', 'Choose a reducer function / calculation'),
- editor: standardEditorsRegistry.get('stats-picker').editor,
- defaultValue: [ReducerID.sum],
- showIf: (cfg) => cfg.footer?.show,
- })
- .addBooleanSwitch({
- path: 'footer.countRows',
- category: footerCategory,
- name: t('table-new.name-count-rows', 'Count rows'),
- description: t('table-new.description-count-rows', 'Display a single count for all data rows'),
- defaultValue: defaultOptions.footer?.countRows,
- showIf: (cfg) => cfg.footer?.reducer?.length === 1 && cfg.footer?.reducer[0] === ReducerID.count,
- })
- .addMultiSelect({
- path: 'footer.fields',
- category: footerCategory,
- name: t('table-new.name-fields', 'Fields'),
- description: t('table-new.description-fields', 'Select the fields that should be calculated'),
- settings: {
- allowCustomValue: false,
- options: [],
- placeholder: t('table-new.placeholder-fields', 'All Numeric Fields'),
- getOptions: async (context: FieldOverrideContext) => {
- const options = [];
- if (context && context.data && context.data.length > 0) {
- const frame = context.data[0];
- for (const field of frame.fields) {
- if (field.type === FieldType.number) {
- const name = getFieldDisplayName(field, frame, context.data);
- const value = field.name;
- options.push({ value, label: name });
- }
- }
- }
- return options;
- },
- },
- defaultValue: '',
- showIf: (cfg) => cfg.footer?.show && !(cfg.footer?.countRows && cfg.footer?.reducer.includes(ReducerID.count)),
- })
- .addCustomEditor({
- id: 'footer.enablePagination',
- path: 'footer.enablePagination',
- name: t('table-new.name-enable-pagination', 'Enable pagination'),
- category,
- editor: PaginationEditor,
- });
- })
- .setSuggestionsSupplier(new TableSuggestionsSupplier());
diff --git a/public/app/plugins/panel/table/table-new/panelcfg.cue b/public/app/plugins/panel/table/table-new/panelcfg.cue
deleted file mode 100644
index 1bfbd053cf2..00000000000
--- a/public/app/plugins/panel/table/table-new/panelcfg.cue
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright 2021 Grafana Labs
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package grafanaplugin
-
-import (
- ui "github.com/grafana/grafana/packages/grafana-schema/src/common"
-)
-
-composableKinds: PanelCfg: {
- maturity: "experimental"
- lineage: {
- schemas: [{
- version: [0, 0]
- schema: {
- Options: {
- // Represents the index of the selected frame
- frameIndex: number | *0
- // Controls whether the panel should show the header
- showHeader: bool | *true
- // Controls whether the header should show icons for the column types
- showTypeIcons?: bool | *false
- // Used to control row sorting
- sortBy?: [...ui.TableSortByFieldState]
- // Controls footer options
- footer?: ui.TableFooterOptions | *{
- // Controls whether the footer should be shown
- show: false
- // Controls whether the footer should show the total number of rows on Count calculation
- countRows: false
- // Represents the selected calculations
- reducer: []
- }
- // Controls the height of the rows
- cellHeight?: ui.TableCellHeight & (*"sm" | _)
- // Defines the number of columns to freeze on the left side of the table
- frozenColumns?: {
- left?: number | *0
- }
- } @cuetsy(kind="interface")
- FieldConfig: {
- ui.TableFieldOptions
- } @cuetsy(kind="interface")
- }
- }]
- lenses: []
- }
-}
diff --git a/public/app/plugins/panel/table/table-new/panelcfg.gen.ts b/public/app/plugins/panel/table/table-new/panelcfg.gen.ts
deleted file mode 100644
index bed8165671a..00000000000
--- a/public/app/plugins/panel/table/table-new/panelcfg.gen.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-// Code generated - EDITING IS FUTILE. DO NOT EDIT.
-//
-// Generated by:
-// public/app/plugins/gen.go
-// Using jennies:
-// TSTypesJenny
-// PluginTsTypesJenny
-//
-// Run 'make gen-cue' from repository root to regenerate.
-
-import * as ui from '@grafana/schema';
-
-export interface Options {
- /**
- * Controls the height of the rows
- */
- cellHeight?: ui.TableCellHeight;
- /**
- * Controls footer options
- */
- footer?: ui.TableFooterOptions;
- /**
- * Represents the index of the selected frame
- */
- frameIndex: number;
- /**
- * number of columns on the left side of the table that should be frozen
- */
- frozenColumns?: {
- left?: number;
- };
- /**
- * Controls whether the panel should show the header
- */
- showHeader: boolean;
- /**
- * Controls whether the header should show icons for the column types
- */
- showTypeIcons?: boolean;
- /**
- * Used to control row sorting
- */
- sortBy?: Array;
-}
-
-export const defaultOptions: Partial = {
- cellHeight: ui.TableCellHeight.Sm,
- footer: {
- /**
- * Controls whether the footer should be shown
- */
- show: false,
- /**
- * Controls whether the footer should show the total number of rows on Count calculation
- */
- countRows: false,
- /**
- * Represents the selected calculations
- */
- reducer: [],
- },
- frameIndex: 0,
- showHeader: true,
- showTypeIcons: false,
- sortBy: [],
-};
-
-export interface FieldConfig extends ui.TableFieldOptions {}
diff --git a/public/app/plugins/panel/table/table-new/plugin.json b/public/app/plugins/panel/table/table-new/plugin.json
deleted file mode 100644
index 36fb974d930..00000000000
--- a/public/app/plugins/panel/table/table-new/plugin.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "type": "panel",
- "name": "Table",
- "id": "table",
- "state": "beta",
-
- "info": {
- "description": "Supports many column styles",
- "author": {
- "name": "Grafana Labs",
- "url": "https://grafana.com"
- },
- "logos": {
- "small": "img/icn-table-panel.svg",
- "large": "img/icn-table-panel.svg"
- },
- "links": [
- { "name": "Raise issue", "url": "https://github.com/grafana/grafana/issues/new" },
- {
- "name": "Documentation",
- "url": "https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/"
- }
- ]
- }
-}
diff --git a/public/app/plugins/panel/table/table-new/suggestions.ts b/public/app/plugins/panel/table/table-new/suggestions.ts
deleted file mode 100644
index bcfead0fe31..00000000000
--- a/public/app/plugins/panel/table/table-new/suggestions.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { VisualizationSuggestionsBuilder } from '@grafana/data';
-import { TableFieldOptions } from '@grafana/schema';
-import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
-import { SuggestionName } from 'app/types/suggestions';
-
-import { Options } from './panelcfg.gen';
-
-export class TableSuggestionsSupplier {
- getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
- const list = builder.getListAppender({
- name: SuggestionName.Table,
- pluginId: 'table',
- options: {},
- fieldConfig: {
- defaults: {
- custom: {},
- },
- overrides: [],
- },
- cardOptions: {
- previewModifier: (s) => {
- s.fieldConfig!.defaults.custom!.minWidth = 50;
- },
- },
- });
-
- // If there are not data suggest table anyway but use icon instead of real preview
- if (builder.dataSummary.fieldCount === 0) {
- list.append({
- cardOptions: {
- imgSrc: icnTablePanelSvg,
- },
- });
- } else {
- list.append({});
- }
- }
-}
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 3c86d003d9a..278716c8265 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -4167,7 +4167,7 @@
},
"collector": {
"subtitle": "Manage the configuration of Grafana Alloy, our distribution of the OpenTelemetry Collector",
- "title": "Collector:"
+ "title": "Collector"
},
"data-sources": {
"subtitle": "Manage your existing data source connections",
@@ -6373,7 +6373,7 @@
},
"dashboards": {
"panel-queries": {
- "add-query-from-library": "Add query from library"
+ "add-from-saved-queries": "Add from saved queries"
},
"settings": {
"variables": {
@@ -12708,10 +12708,6 @@
"login": "Login"
},
"table": {
- "auto-cell-options-editor": {
- "description-wrap-text": "If selected text will be wrapped to the width of text in the configured column",
- "label-wrap-text": "Wrap text"
- },
"bar-gauge-cell-options-editor": {
"label-gauge-display-mode": "Gauge display mode",
"label-value-display": "Value display"
@@ -12739,13 +12735,8 @@
},
"color-background-cell-options-editor": {
"description-apply-to-entire-row": "If selected the entire row will be colored as this cell would be.",
- "description-wrap-text": "If selected text will be wrapped to the width of text in the configured column",
- "label": {
- "text-alpha": "Alpha"
- },
"label-apply-to-entire-row": "Apply to entire row",
- "label-background-display-mode": "Background display mode",
- "wrap-text": "Wrap text"
+ "label-background-display-mode": "Background display mode"
},
"column-alignment-options": {
"label-auto": "Auto",
@@ -12763,7 +12754,9 @@
"description-column-filter": "Enables/disables field filters in table",
"description-count-rows": "Display a single count for all data rows",
"description-fields": "Select the fields that should be calculated",
+ "description-frozen-columns": "Columns are frozen from the left side of the table",
"description-min-column-width": "The minimum width for column auto resizing",
+ "description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
"description-wrap-header-text": "Enables text wrapping for column headers",
"image-cell-options-editor": {
"description-alt-text": "Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader",
@@ -12789,50 +12782,6 @@
"name-column-filter": "Column filter",
"name-column-width": "Column width",
"name-count-rows": "Count rows",
- "name-enable-paginations": "Enable pagination",
- "name-fields": "Fields",
- "name-hide-in-table": "Hide in table",
- "name-min-column-width": "Minimum column width",
- "name-show-table-footer": "Show table footer",
- "name-show-table-header": "Show table header",
- "name-wrap-header-text": "Wrap header text",
- "placeholder-column-width": "auto",
- "placeholder-fields": "All Numeric Fields",
- "text-wrap-options": {
- "label-wrap-text": "Wrap text"
- }
- },
- "table-new": {
- "category-cell-options": "Cell options",
- "category-table": "Table",
- "category-table-footer": "Table footer",
- "cell-height-options": {
- "label-large": "Large",
- "label-medium": "Medium",
- "label-small": "Small"
- },
- "column-alignment-options": {
- "label-auto": "Auto",
- "label-center": "Center",
- "label-left": "Left",
- "label-right": "Right"
- },
- "description-calculation": "Choose a reducer function / calculation",
- "description-cell-value-inspect": "Enable cell value inspection in a modal window",
- "description-column-filter": "Enables/disables field filters in table",
- "description-count-rows": "Display a single count for all data rows",
- "description-fields": "Select the fields that should be calculated",
- "description-frozen-columns": "Columns are frozen from the left side of the table",
- "description-min-column-width": "The minimum width for column auto resizing",
- "description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
- "name-calculation": "Calculation",
- "name-cell-height": "Cell height",
- "name-cell-type": "Cell type",
- "name-cell-value-inspect": "Cell value inspect",
- "name-column-alignment": "Column alignment",
- "name-column-filter": "Column filter",
- "name-column-width": "Column width",
- "name-count-rows": "Count rows",
"name-enable-pagination": "Enable pagination",
"name-fields": "Fields",
"name-frozen-columns": "Frozen columns",
@@ -12842,8 +12791,12 @@
"name-show-table-header": "Show table header",
"name-tooltip-from-field": "Tooltip from field",
"name-tooltip-placement": "Tooltip placement",
+ "name-wrap-header-text": "Wrap header text",
"placeholder-column-width": "auto",
"placeholder-fields": "All Numeric Fields",
+ "text-wrap-options": {
+ "label-wrap-text": "Wrap text"
+ },
"tooltip-placement-options": {
"label-auto": "Auto",
"label-bottom": "Bottom",
diff --git a/yarn.lock b/yarn.lock
index dfd8656a6b8..0bd82fd1044 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -28781,7 +28781,7 @@ __metadata:
languageName: node
linkType: hard
-"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:~5.2.0":
+"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451
@@ -29115,14 +29115,15 @@ __metadata:
linkType: hard
"sha.js@npm:^2.4.11":
- version: 2.4.11
- resolution: "sha.js@npm:2.4.11"
+ version: 2.4.12
+ resolution: "sha.js@npm:2.4.12"
dependencies:
- inherits: "npm:^2.0.1"
- safe-buffer: "npm:^5.0.1"
+ inherits: "npm:^2.0.4"
+ safe-buffer: "npm:^5.2.1"
+ to-buffer: "npm:^1.2.0"
bin:
- sha.js: ./bin.js
- checksum: 10/d833bfa3e0a67579a6ce6e1bc95571f05246e0a441dd8c76e3057972f2a3e098465687a4369b07e83a0375a88703577f71b5b2e966809e67ebc340dbedb478c7
+ sha.js: bin.js
+ checksum: 10/39c0993592c2ab34eb2daae2199a2a1d502713765aecb611fd97c0c4ab7cd53e902d628e1962aaf384bafd28f55951fef46dcc78799069ce41d74b03aa13b5a7
languageName: node
linkType: hard
@@ -31021,6 +31022,17 @@ __metadata:
languageName: node
linkType: hard
+"to-buffer@npm:^1.2.0":
+ version: 1.2.1
+ resolution: "to-buffer@npm:1.2.1"
+ dependencies:
+ isarray: "npm:^2.0.5"
+ safe-buffer: "npm:^5.2.1"
+ typed-array-buffer: "npm:^1.0.3"
+ checksum: 10/f8d03f070b8567d9c949f1b59c8d47c83ed2e59b50b5449258f931df9a1fcb751aa8bb8756a9345adc529b6b1822521157c48e1a7d01779a47185060d7bf96d4
+ languageName: node
+ linkType: hard
+
"to-camel-case@npm:1.0.0":
version: 1.0.0
resolution: "to-camel-case@npm:1.0.0"