Merge branch 'main' into ash/react-19
This commit is contained in:
@@ -258,4 +258,6 @@ export interface FeatureToggles {
|
|||||||
infinityRunQueriesInParallel?: boolean;
|
infinityRunQueriesInParallel?: boolean;
|
||||||
inviteUserExperimental?: boolean;
|
inviteUserExperimental?: boolean;
|
||||||
extraLanguages?: boolean;
|
extraLanguages?: boolean;
|
||||||
|
noBackdropBlur?: boolean;
|
||||||
|
alertingMigrationUI?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getExtraStyles } from './extra';
|
|||||||
import { getFilterTableStyles } from './filterTable';
|
import { getFilterTableStyles } from './filterTable';
|
||||||
import { getFontStyles } from './fonts';
|
import { getFontStyles } from './fonts';
|
||||||
import { getFormElementStyles } from './forms';
|
import { getFormElementStyles } from './forms';
|
||||||
|
import { getHacksStyles } from './hacks';
|
||||||
import { getJsonFormatterStyles } from './jsonFormatter';
|
import { getJsonFormatterStyles } from './jsonFormatter';
|
||||||
import { getLegacySelectStyles } from './legacySelect';
|
import { getLegacySelectStyles } from './legacySelect';
|
||||||
import { getMarkdownStyles } from './markdownStyles';
|
import { getMarkdownStyles } from './markdownStyles';
|
||||||
@@ -24,9 +25,14 @@ import { getSlateStyles } from './slate';
|
|||||||
import { getUplotStyles } from './uPlot';
|
import { getUplotStyles } from './uPlot';
|
||||||
import { getUtilityClassStyles } from './utilityClasses';
|
import { getUtilityClassStyles } from './utilityClasses';
|
||||||
|
|
||||||
|
interface GlobalStylesProps {
|
||||||
|
hackNoBackdropBlur?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function GlobalStyles() {
|
export function GlobalStyles(props: GlobalStylesProps) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
|
const { hackNoBackdropBlur } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Global
|
<Global
|
||||||
@@ -52,6 +58,7 @@ export function GlobalStyles() {
|
|||||||
getUplotStyles(theme),
|
getUplotStyles(theme),
|
||||||
getUtilityClassStyles(theme),
|
getUtilityClassStyles(theme),
|
||||||
getLegacySelectStyles(theme),
|
getLegacySelectStyles(theme),
|
||||||
|
getHacksStyles({ hackNoBackdropBlur }),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { css } from '@emotion/react';
|
||||||
|
|
||||||
|
export interface Hacks {
|
||||||
|
hackNoBackdropBlur?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHacksStyles(hacks: Hacks) {
|
||||||
|
return css([
|
||||||
|
/**
|
||||||
|
* Disables all backdrop blur effects to improve performance on extremely
|
||||||
|
* resource constrained devices.
|
||||||
|
*
|
||||||
|
* Controlled via the `noBackdropBlur` feature toggle in Grafana
|
||||||
|
*/
|
||||||
|
hacks.hackNoBackdropBlur && {
|
||||||
|
'*, *:before, *:after': {
|
||||||
|
backdropFilter: 'none !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1811,6 +1811,24 @@ var (
|
|||||||
Owner: grafanaFrontendPlatformSquad,
|
Owner: grafanaFrontendPlatformSquad,
|
||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "noBackdropBlur",
|
||||||
|
Description: "Disables backdrop blur",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaFrontendPlatformSquad,
|
||||||
|
HideFromAdminPage: true,
|
||||||
|
HideFromDocs: true,
|
||||||
|
FrontendOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "alertingMigrationUI",
|
||||||
|
Description: "Enables the alerting migration UI, to migrate datasource-managed rules to Grafana-managed rules",
|
||||||
|
FrontendOnly: true,
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaAlertingSquad,
|
||||||
|
HideFromAdminPage: true,
|
||||||
|
HideFromDocs: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -239,3 +239,5 @@ grafanaManagedRecordingRulesDatasources,experimental,@grafana/alerting-squad,fal
|
|||||||
infinityRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false
|
infinityRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false
|
||||||
inviteUserExperimental,experimental,@grafana/sharing-squad,false,false,true
|
inviteUserExperimental,experimental,@grafana/sharing-squad,false,false,true
|
||||||
extraLanguages,experimental,@grafana/grafana-frontend-platform,false,false,true
|
extraLanguages,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||||
|
noBackdropBlur,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||||
|
alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true
|
||||||
|
|||||||
|
@@ -966,4 +966,12 @@ const (
|
|||||||
// FlagExtraLanguages
|
// FlagExtraLanguages
|
||||||
// Enables additional languages
|
// Enables additional languages
|
||||||
FlagExtraLanguages = "extraLanguages"
|
FlagExtraLanguages = "extraLanguages"
|
||||||
|
|
||||||
|
// FlagNoBackdropBlur
|
||||||
|
// Disables backdrop blur
|
||||||
|
FlagNoBackdropBlur = "noBackdropBlur"
|
||||||
|
|
||||||
|
// FlagAlertingMigrationUI
|
||||||
|
// Enables the alerting migration UI, to migrate datasource-managed rules to Grafana-managed rules
|
||||||
|
FlagAlertingMigrationUI = "alertingMigrationUI"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -322,6 +322,21 @@
|
|||||||
"frontend": true
|
"frontend": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "alertingMigrationUI",
|
||||||
|
"resourceVersion": "1741968018953",
|
||||||
|
"creationTimestamp": "2025-03-14T16:00:18Z"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"description": "Enables the alerting migration UI, to migrate datasource-managed rules to Grafana-managed rules",
|
||||||
|
"stage": "experimental",
|
||||||
|
"codeowner": "@grafana/alerting-squad",
|
||||||
|
"frontend": true,
|
||||||
|
"hideFromAdminPage": true,
|
||||||
|
"hideFromDocs": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "alertingNoDataErrorExecution",
|
"name": "alertingNoDataErrorExecution",
|
||||||
@@ -2913,6 +2928,21 @@
|
|||||||
"hideFromDocs": true
|
"hideFromDocs": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "noBackdropBlur",
|
||||||
|
"resourceVersion": "1741879106163",
|
||||||
|
"creationTimestamp": "2025-03-13T15:18:26Z"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"description": "Disables backdrop blur",
|
||||||
|
"stage": "experimental",
|
||||||
|
"codeowner": "@grafana/grafana-frontend-platform",
|
||||||
|
"frontend": true,
|
||||||
|
"hideFromAdminPage": true,
|
||||||
|
"hideFromDocs": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "nodeGraphDotLayout",
|
"name": "nodeGraphDotLayout",
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package debouncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/dskit/instrument"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBufferFull = errors.New("debouncer buffer full")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessFunc[T comparable] func(context.Context, T) error
|
||||||
|
type ErrorFunc[T comparable] func(T, error)
|
||||||
|
|
||||||
|
type metrics struct {
|
||||||
|
itemsAddedCounter prometheus.Counter
|
||||||
|
itemsDroppedCounter prometheus.Counter
|
||||||
|
itemsProcessedCounter prometheus.Counter
|
||||||
|
processingErrorsCounter prometheus.Counter
|
||||||
|
processingDurationHistogram prometheus.Histogram
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetrics(reg prometheus.Registerer, name string) *metrics {
|
||||||
|
return &metrics{
|
||||||
|
itemsAddedCounter: promauto.With(reg).NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "debouncer_items_added_total",
|
||||||
|
Help: "Total number of items added to the debouncer",
|
||||||
|
ConstLabels: prometheus.Labels{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
itemsDroppedCounter: promauto.With(reg).NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "debouncer_items_dropped_total",
|
||||||
|
Help: "Total number of items dropped due to a full buffer",
|
||||||
|
ConstLabels: prometheus.Labels{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
itemsProcessedCounter: promauto.With(reg).NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "debouncer_items_processed_total",
|
||||||
|
Help: "Total number of items processed by the debouncer",
|
||||||
|
ConstLabels: prometheus.Labels{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
processingErrorsCounter: promauto.With(reg).NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "debouncer_processing_errors_total",
|
||||||
|
Help: "Total number of errors during processing",
|
||||||
|
ConstLabels: prometheus.Labels{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
processingDurationHistogram: promauto.With(reg).NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: "debouncer_processing_duration_seconds",
|
||||||
|
Help: "Time taken to process items",
|
||||||
|
Buckets: instrument.DefBuckets,
|
||||||
|
NativeHistogramBucketFactor: 1.1,
|
||||||
|
NativeHistogramMaxBucketNumber: 160,
|
||||||
|
NativeHistogramMinResetDuration: time.Hour,
|
||||||
|
ConstLabels: prometheus.Labels{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebouncerOpts hold all the options to create a debouncer group.
|
||||||
|
type DebouncerOpts[T comparable] struct {
|
||||||
|
// Name should be a unique name for this debouncer group. It is
|
||||||
|
// also used a name label value for the metrics.
|
||||||
|
Name string
|
||||||
|
// BufferSize is the maximum number of pending events to buffer.
|
||||||
|
BufferSize int
|
||||||
|
|
||||||
|
// ErrorHandler is the function that is called when a process for a given
|
||||||
|
// key returns an error while running.
|
||||||
|
ErrorHandler ErrorFunc[T]
|
||||||
|
// ProcessHandler is the function that is called once a process for a given
|
||||||
|
// key should be run.
|
||||||
|
ProcessHandler ProcessFunc[T]
|
||||||
|
// MinWait is the cooldown period after receiving an event. If another event with the
|
||||||
|
// same key arrives during this period, the timer resets and we wait another MinWait duration.
|
||||||
|
MinWait time.Duration
|
||||||
|
// MaxWait is the maximum time any event will wait before processing. Even if new events
|
||||||
|
// for the same key keep arriving, we guarantee processing after MaxWait from the first event.
|
||||||
|
MaxWait time.Duration
|
||||||
|
Reg prometheus.Registerer
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group[T comparable] struct {
|
||||||
|
buffer chan T
|
||||||
|
|
||||||
|
// mutex protecting the debouncers map.
|
||||||
|
debouncersMu sync.Mutex
|
||||||
|
debouncers map[T]*debouncer[T]
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
errorHandler ErrorFunc[T]
|
||||||
|
processHandler ProcessFunc[T]
|
||||||
|
minWait time.Duration
|
||||||
|
maxWait time.Duration
|
||||||
|
metrics *metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroup creates a new debouncer group for processing events with unique keys.
|
||||||
|
//
|
||||||
|
// A debouncer group helps optimize expensive operations by:
|
||||||
|
// 1. Grouping identical events that occur in rapid succession
|
||||||
|
// 2. Processing each unique key only once after waiting periods expire
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// group := debouncer.NewGroup(DebouncerOpts[string]{
|
||||||
|
// BufferSize: 1000,
|
||||||
|
// ProcessHandler: func(ctx context.Context, key string) error {
|
||||||
|
// // This is where you perform the expensive operation
|
||||||
|
// return doSuperExpensiveCommand(key)
|
||||||
|
// }
|
||||||
|
// MinWait: time.Second * 10,
|
||||||
|
// MaxWait: time.Minute,
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// // Start the debouncer group.
|
||||||
|
// group.Start(ctx)
|
||||||
|
//
|
||||||
|
// // Queue events
|
||||||
|
// if err := group.Add("user-1"); err != nil {
|
||||||
|
// // Do something with the error.
|
||||||
|
// }
|
||||||
|
// // Adding the same key resets MinWait but not MaxWait
|
||||||
|
// if err := group.Add("user-1"); err != nil {
|
||||||
|
// // Do something with the error.
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The event will be processed when either MinWait expires (after the most recent add)
|
||||||
|
// or MaxWait expires (after the first add), whichever comes first.
|
||||||
|
func NewGroup[T comparable](opts DebouncerOpts[T]) (*Group[T], error) {
|
||||||
|
if opts.BufferSize <= 0 {
|
||||||
|
opts.BufferSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.MinWait <= 0 {
|
||||||
|
opts.MinWait = time.Minute
|
||||||
|
}
|
||||||
|
if opts.MaxWait <= 0 {
|
||||||
|
opts.MaxWait = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if opts.MinWait > opts.MaxWait {
|
||||||
|
return nil, errors.New("minWait is bigger than maxWait")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ProcessHandler == nil {
|
||||||
|
return nil, errors.New("processHandler is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ErrorHandler == nil {
|
||||||
|
opts.ErrorHandler = func(_ T, _ error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Group[T]{
|
||||||
|
buffer: make(chan T, opts.BufferSize),
|
||||||
|
debouncers: make(map[T]*debouncer[T]),
|
||||||
|
processHandler: opts.ProcessHandler,
|
||||||
|
errorHandler: opts.ErrorHandler,
|
||||||
|
minWait: opts.MinWait,
|
||||||
|
maxWait: opts.MaxWait,
|
||||||
|
metrics: newMetrics(opts.Reg, opts.Name),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add will create a new debouncer for the given Key if it doesn't exist yet.
|
||||||
|
// If a key has already a debouncer it will either reset the MinWait timer for
|
||||||
|
// this key, or if they key is already running its process be no-op.
|
||||||
|
func (g *Group[T]) Add(value T) error {
|
||||||
|
select {
|
||||||
|
case g.buffer <- value:
|
||||||
|
g.metrics.itemsAddedCounter.Inc()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
g.metrics.itemsDroppedCounter.Inc()
|
||||||
|
return ErrBufferFull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Group[T]) Start(ctx context.Context) {
|
||||||
|
g.ctx, g.cancel = context.WithCancel(ctx)
|
||||||
|
g.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer g.wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-g.ctx.Done():
|
||||||
|
return
|
||||||
|
case value := <-g.buffer:
|
||||||
|
g.processValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Group[T]) Stop() {
|
||||||
|
if g.cancel != nil {
|
||||||
|
g.cancel()
|
||||||
|
g.wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Group[T]) processValue(key T) {
|
||||||
|
g.debouncersMu.Lock()
|
||||||
|
deb, ok := g.debouncers[key]
|
||||||
|
if !ok {
|
||||||
|
deb = newDebouncer[T](g.minWait, g.maxWait, key, func(v T) {
|
||||||
|
g.processWithMetrics(g.ctx, v, g.processHandler)
|
||||||
|
|
||||||
|
g.debouncersMu.Lock()
|
||||||
|
defer g.debouncersMu.Unlock()
|
||||||
|
if current, exists := g.debouncers[key]; exists && current == deb {
|
||||||
|
delete(g.debouncers, key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
g.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer g.wg.Done()
|
||||||
|
deb.run(g.ctx)
|
||||||
|
}()
|
||||||
|
g.debouncers[key] = deb
|
||||||
|
}
|
||||||
|
g.debouncersMu.Unlock()
|
||||||
|
|
||||||
|
deb.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Group[T]) processWithMetrics(ctx context.Context, value T, processFunc ProcessFunc[T]) {
|
||||||
|
timer := prometheus.NewTimer(g.metrics.processingDurationHistogram)
|
||||||
|
defer timer.ObserveDuration()
|
||||||
|
g.metrics.itemsProcessedCounter.Inc()
|
||||||
|
|
||||||
|
if err := processFunc(ctx, value); err != nil {
|
||||||
|
g.errorHandler(value, err)
|
||||||
|
g.metrics.processingErrorsCounter.Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debouncer handles debouncing for a specific key.
|
||||||
|
type debouncer[T comparable] struct {
|
||||||
|
key T
|
||||||
|
resetChan chan struct{}
|
||||||
|
minWait time.Duration
|
||||||
|
maxWait time.Duration
|
||||||
|
processFunc func(T)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDebouncer creates a new key debouncer.
|
||||||
|
func newDebouncer[T comparable](minWait, maxWait time.Duration, key T, processFunc func(T)) *debouncer[T] {
|
||||||
|
deb := &debouncer[T]{
|
||||||
|
key: key,
|
||||||
|
resetChan: make(chan struct{}, 1),
|
||||||
|
minWait: minWait,
|
||||||
|
maxWait: maxWait,
|
||||||
|
processFunc: processFunc,
|
||||||
|
}
|
||||||
|
return deb
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset triggers a timer reset for the minWait.
|
||||||
|
func (d *debouncer[T]) reset() {
|
||||||
|
select {
|
||||||
|
case d.resetChan <- struct{}{}:
|
||||||
|
// Value sent successfully.
|
||||||
|
default:
|
||||||
|
// Value was dropped. Is not an issue as
|
||||||
|
// a reset is already about to being processed
|
||||||
|
// or the process is being run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run manages the debouncing process for a specific key.
|
||||||
|
func (d *debouncer[T]) run(ctx context.Context) {
|
||||||
|
// Create timers after getting the first updateChan.
|
||||||
|
minTimer := time.NewTimer(d.minWait)
|
||||||
|
maxTimer := time.NewTimer(d.maxWait)
|
||||||
|
defer func() {
|
||||||
|
minTimer.Stop()
|
||||||
|
maxTimer.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-d.resetChan:
|
||||||
|
minTimer.Stop()
|
||||||
|
minTimer.Reset(d.minWait)
|
||||||
|
case <-minTimer.C:
|
||||||
|
d.processFunc(d.key)
|
||||||
|
return
|
||||||
|
case <-maxTimer.C:
|
||||||
|
d.processFunc(d.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package debouncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDebouncer(t *testing.T) {
|
||||||
|
t.Run("should process values after min wait", func(t *testing.T) {
|
||||||
|
var processedMu sync.Mutex
|
||||||
|
processedValues := make(map[string]int)
|
||||||
|
|
||||||
|
group, err := NewGroup(DebouncerOpts[string]{
|
||||||
|
BufferSize: 10,
|
||||||
|
ProcessHandler: func(ctx context.Context, value string) error {
|
||||||
|
processedMu.Lock()
|
||||||
|
processedValues[value]++
|
||||||
|
processedMu.Unlock()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
MinWait: 10 * time.Millisecond,
|
||||||
|
MaxWait: 500 * time.Millisecond,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
group.Start(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, group.Add("key1"))
|
||||||
|
require.NoError(t, group.Add("key2"))
|
||||||
|
// Should be deduplicated.
|
||||||
|
require.NoError(t, group.Add("key1"))
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
// We should have processed key1 and key2 exactly once.
|
||||||
|
processedMu.Lock()
|
||||||
|
if processedValues["key1"] == 1 && processedValues["key2"] == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
processedMu.Unlock()
|
||||||
|
return false
|
||||||
|
}, time.Millisecond*200, time.Millisecond*20)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should process values after max wait", func(t *testing.T) {
|
||||||
|
processed := make(map[string]int, 1)
|
||||||
|
|
||||||
|
group, err := NewGroup(DebouncerOpts[string]{
|
||||||
|
BufferSize: 10,
|
||||||
|
ProcessHandler: func(ctx context.Context, value string) error {
|
||||||
|
processed[value]++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
MinWait: 50 * time.Millisecond,
|
||||||
|
MaxWait: 500 * time.Millisecond,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
group.Start(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Millisecond * 40)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
for counter := 0; counter < 25; counter++ {
|
||||||
|
<-ticker.C
|
||||||
|
_ = group.Add("key1")
|
||||||
|
if processed["key1"] == 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.WithinDuration(t, start.Add(time.Millisecond*500), time.Now(), time.Millisecond*100)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle buffer full", func(t *testing.T) {
|
||||||
|
group, err := NewGroup(DebouncerOpts[string]{
|
||||||
|
BufferSize: 1,
|
||||||
|
ProcessHandler: func(ctx context.Context, value string) error { return nil },
|
||||||
|
MinWait: 10 * time.Millisecond,
|
||||||
|
MaxWait: 100 * time.Millisecond,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, group.Add("key1"))
|
||||||
|
// Buffer should be full by now as we are not reading from it yet.
|
||||||
|
require.ErrorIs(t, group.Add("key2"), ErrBufferFull)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should track metrics", func(t *testing.T) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
group, err := NewGroup(DebouncerOpts[string]{
|
||||||
|
BufferSize: 10,
|
||||||
|
ProcessHandler: func(ctx context.Context, value string) error {
|
||||||
|
wg.Done()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
MinWait: 10 * time.Millisecond,
|
||||||
|
MaxWait: 100 * time.Millisecond,
|
||||||
|
|
||||||
|
Name: "test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
group.Start(ctx)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
require.NoError(t, group.Add("key1"))
|
||||||
|
require.NoError(t, group.Add("key1"))
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
require.Equal(t, float64(2), testutil.ToFloat64(group.metrics.itemsAddedCounter))
|
||||||
|
require.Equal(t, float64(1), testutil.ToFloat64(group.metrics.itemsProcessedCounter))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle errors", func(t *testing.T) {
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
errs = make(chan error, 10)
|
||||||
|
expectedErr = errors.New("test error")
|
||||||
|
)
|
||||||
|
|
||||||
|
group, err := NewGroup(DebouncerOpts[string]{
|
||||||
|
BufferSize: 10,
|
||||||
|
ProcessHandler: func(ctx context.Context, value string) error {
|
||||||
|
wg.Done()
|
||||||
|
return expectedErr
|
||||||
|
},
|
||||||
|
MinWait: 10 * time.Millisecond,
|
||||||
|
MaxWait: 100 * time.Millisecond,
|
||||||
|
Reg: prometheus.NewPedanticRegistry(),
|
||||||
|
Name: "test_errors",
|
||||||
|
ErrorHandler: func(_ string, err error) { errs <- err },
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
group.Start(ctx)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
require.NoError(t, group.Add("key1"))
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errs:
|
||||||
|
require.Equal(t, expectedErr, err)
|
||||||
|
default:
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, float64(1), testutil.ToFloat64(group.metrics.processingErrorsCounter))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should gracefully handle stops", func(t *testing.T) {
|
||||||
|
// Create a channel to signal when processing is done.
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
group, err := NewGroup(DebouncerOpts[string]{
|
||||||
|
BufferSize: 10,
|
||||||
|
ProcessHandler: func(ctx context.Context, item string) error {
|
||||||
|
// Start a goroutine to wait for context cancellation.
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
MinWait: 50 * time.Millisecond,
|
||||||
|
MaxWait: 500 * time.Millisecond,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Start the group with a context
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
group.Start(ctx)
|
||||||
|
|
||||||
|
// Send an item to trigger processing.
|
||||||
|
require.NoError(t, group.Add("key-1"))
|
||||||
|
|
||||||
|
// Give the group a moment to process the item.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Stop the group, which should cancel the context.
|
||||||
|
group.Stop()
|
||||||
|
|
||||||
|
// Wait for the done signal or timeout.
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - the group was stopped and the context was canceled
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timed out waiting for group to stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
|||||||
actions={[]}
|
actions={[]}
|
||||||
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
|
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
|
||||||
>
|
>
|
||||||
<GlobalStyles />
|
<GlobalStyles hackNoBackdropBlur={config.featureToggles.noBackdropBlur} />
|
||||||
<MaybeTimeRangeProvider>
|
<MaybeTimeRangeProvider>
|
||||||
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
||||||
<ScopesContextProvider>
|
<ScopesContextProvider>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Afterwards, you need to run the `TestIntegrationOpenAPIs` test. Note that it wil
|
|||||||
|
|
||||||
### 2. Create the API definition
|
### 2. Create the API definition
|
||||||
|
|
||||||
In the `../public/app/features/{your_group_name}/api/` folder you have to create the `baseAPI.ts` file for your group. This file should have the following content:
|
In the [`/public/app/api/clients`](/public/app/api/clients) folder, create a new folder and `baseAPI.ts` file for your group. This file should have the following content:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||||
@@ -34,7 +34,7 @@ import { getAPIBaseURL } from 'app/api/utils';
|
|||||||
|
|
||||||
export const BASE_URL = getAPIBaseURL('dashboard.grafana.app', 'v0alpha1');
|
export const BASE_URL = getAPIBaseURL('dashboard.grafana.app', 'v0alpha1');
|
||||||
|
|
||||||
export const baseAPI = createApi({
|
export const api = createApi({
|
||||||
reducerPath: 'dashboardAPI',
|
reducerPath: 'dashboardAPI',
|
||||||
baseQuery: createBaseQuery({
|
baseQuery: createBaseQuery({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
@@ -43,9 +43,9 @@ export const baseAPI = createApi({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the API definition for the specific group you're working with, where `getAPIBaseURL` should have the proper `group` and `version` as parameters. The `reducePath` should also be modified to match `group + API`: `dashboard` will be `dashboardAPI`, `iam` will be `iamAPI` and so on.
|
This is the API definition for the specific group you're working with, where `getAPIBaseURL` should have the proper `group` and `version` as parameters. The `reducerPath` needs to be unique. The convention is to use `<group>API`: `dashboard` will be `dashboardAPI`, `iam` will be `iamAPI` and so on.
|
||||||
|
|
||||||
### 3. Add the output information
|
### 3. Add your new client to the generation script
|
||||||
|
|
||||||
Open [generate-rtk-apis.ts](scripts/generate-rtk-apis.ts) and add the following information:
|
Open [generate-rtk-apis.ts](scripts/generate-rtk-apis.ts) and add the following information:
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ Open [generate-rtk-apis.ts](scripts/generate-rtk-apis.ts) and add the following
|
|||||||
| outputFile name | File that will be created after running the API Client Generation script. It is the key of the object. |
|
| outputFile name | File that will be created after running the API Client Generation script. It is the key of the object. |
|
||||||
| apiFile | File with the group's API definition. |
|
| apiFile | File with the group's API definition. |
|
||||||
| schemaFile | File with the schema that was automatically created in the second step. Although it is in openapi_snapshots, you should link the one saved in `data/openapi`. |
|
| schemaFile | File with the schema that was automatically created in the second step. Although it is in openapi_snapshots, you should link the one saved in `data/openapi`. |
|
||||||
| apiImport | Function name exported in the API definition (baseAPI.ts file). |
|
|
||||||
| filterEndpoints | The `operationId` of the particular route you want to work with. You can check the available operationIds in the specific group's spec file. As seen in the `migrate-to-cloud` one, it is an array |
|
| filterEndpoints | The `operationId` of the particular route you want to work with. You can check the available operationIds in the specific group's spec file. As seen in the `migrate-to-cloud` one, it is an array |
|
||||||
| tag | Must be set to `true`, to automatically attach tags to endpoints. This is needed for proper cache invalidation. See more info in the [official documentation](https://redux-toolkit.js.org/rtk-query/usage/automated-refetching#:~:text=RTK%20Query%20uses,an%20active%20subscription.). |
|
| tag | Must be set to `true`, to automatically attach tags to endpoints. This is needed for proper cache invalidation. See more info in the [official documentation](https://redux-toolkit.js.org/rtk-query/usage/automated-refetching#:~:text=RTK%20Query%20uses,an%20active%20subscription.). |
|
||||||
|
|
||||||
@@ -65,16 +64,15 @@ Open [generate-rtk-apis.ts](scripts/generate-rtk-apis.ts) and add the following
|
|||||||
In our example, the information added will be:
|
In our example, the information added will be:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
'../public/app/features/dashboard/api/endpoints.gen.ts': {
|
'../public/app/api/clients/dashboard/endpoints.gen.ts': {
|
||||||
apiFile: '../public/app/features/dashboard/api/baseAPI.ts',
|
apiFile: '../public/app/api/clients/dashboard/baseAPI.ts',
|
||||||
schemaFile: '../data/openapi/dashboard.grafana.app-v0alpha1.json',
|
schemaFile: '../data/openapi/dashboard.grafana.app-v0alpha1.json',
|
||||||
apiImport: 'baseAPI',
|
|
||||||
filterEndpoints: ['createDashboard', 'updateDashboard'],
|
filterEndpoints: ['createDashboard', 'updateDashboard'],
|
||||||
tag: true,
|
tag: true,
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run the API Client script
|
### 4. Run the API client generation script
|
||||||
|
|
||||||
Then, we are ready to run the script to create the API client:
|
Then, we are ready to run the script to create the API client:
|
||||||
|
|
||||||
@@ -100,7 +98,7 @@ export { type Dashboard } from './endpoints.gen';
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There are some use cases where the hook will not work, and that is a clue to see if it needs to be modified. The hooks can be tweaked by using `enhanceEndpoints`.
|
There are some use cases where the hook will not work out of the box, and that is a clue to see if it needs to be modified. The hooks can be tweaked by using `enhanceEndpoints`.
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
export const dashboardsAPI = generatedApi.enhanceEndpoints({
|
export const dashboardsAPI = generatedApi.enhanceEndpoints({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CompatRouter } from 'react-router-dom-v5-compat';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data/';
|
import { GrafanaTheme2 } from '@grafana/data/';
|
||||||
import {
|
import {
|
||||||
|
config,
|
||||||
locationService,
|
locationService,
|
||||||
LocationServiceProvider,
|
LocationServiceProvider,
|
||||||
useChromeHeaderHeight,
|
useChromeHeaderHeight,
|
||||||
@@ -114,7 +115,7 @@ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) {
|
|||||||
<Router history={locationService.getHistory()}>
|
<Router history={locationService.getHistory()}>
|
||||||
<LocationServiceProvider service={locationService}>
|
<LocationServiceProvider service={locationService}>
|
||||||
<CompatRouter>
|
<CompatRouter>
|
||||||
<GlobalStyles />
|
<GlobalStyles hackNoBackdropBlur={config.featureToggles.noBackdropBlur} />
|
||||||
<div className={styles.secondAppChrome}>
|
<div className={styles.secondAppChrome}>
|
||||||
<div className={styles.secondAppToolbar}>
|
<div className={styles.secondAppToolbar}>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
Reference in New Issue
Block a user