Compare commits

...

12 Commits

Author SHA1 Message Date
Victor Marin
44029c2631 refactor 2026-01-13 15:21:24 +02:00
Victor Marin
0573be1920 rename tooltips 2026-01-13 10:39:36 +02:00
Oscar Kilhed
91ab753368 Dynamic Dashboards: Fix navigation to repeated panels and update outline when lazy items repeat (#116030)
Dashboard Outline: Fix navigation to repeated panels and lazy-loaded repeats

- Remove cursor: not-allowed styling from repeated panels in outline
- Add RepeatsUpdatedEvent to notify when panel repeats are populated
- Subscribe to RepeatsUpdatedEvent in DashboardEditPane to refresh outline
- Remove memoization from visibleChildren to ensure outline updates on re-render
2026-01-13 08:43:50 +01:00
Alex Khomenko
250ca7985f Provisioning: Add Connections page (#116060)
* Provisioning: Add connections page

* Provisioning: Add connections form

* Provisioning: Add connections form

* Update fields

* Fix generated name

* Update connection name

* Add edit page

* error handling

* Form validation

* Add Connections button

* Cleanup

* Extract ConnectionFormData type

* Add list test and separate empty states

* Add form test

* Update tests

* i18n

* Cleanup

* Use SecretTextArea from grafana-ui

* Fix breadcrumbs

* tweaks

* Add missing URL

* Switch to ShowConfirmModalEvent

* i18n

* redirect to list on success

* add timeout

* Fix tags invalidation
2026-01-13 08:25:40 +02:00
Hugo Häggmark
b57ed32484 chore: remove app/core/config barrel files (#116068) 2026-01-13 06:23:21 +01:00
Galen Kistler
d0217588a3 LogsDrilldown: Remove exploreLogsLimitedTimeRange flag (#116177)
chore: remove flag
2026-01-12 22:43:01 +00:00
Denis Vodopianov
ce9ab6a89a Add non-boolean feature flags support to the StaticProvider (#115085)
* initial commit

* add support of integerts

* finialise the static provider

* minor refactoring

* the rest

* revert:  the rest

* add new thiongs

* more tests added

* add ff parsing tests to check if types are handled correctly

* update tests according to recent changes

* address golint issues

* Update pkg/setting/setting_feature_toggles.go

Co-authored-by: Dave Henderson <dave.henderson@grafana.com>

* fix rebase issues

* addressing review comments

* add test cases for enterprise

* handle enterprise cases

* minor refactoring to make api a bit easier to debug

* make test names a bit more precise

* fix linter

* add openfeature sdk to goleak ignore in testutil

* Remove only boolean check in ff gen tests

* add non-boolean types top the doc in default.ini and doc string in FeatureFlag type

* apply remarks, add docs to sample.ini

* reflect changes in feature flags in the public grafana configuration doc

* fix doc formatting

* apply suggestions to the doc file

---------

Co-authored-by: Dave Henderson <dave.henderson@grafana.com>
2026-01-12 22:53:23 +01:00
Will Assis
8c8efd2494 unified-storage: skip sqlkv/sqlbackend compatibility tests in sqlite (#116164) 2026-01-12 16:31:29 -05:00
Will Assis
69ccfd6bfc unified-storage: fix sharedwithme search not returning folders (#116089)
* unified-storage: fix dashboard sharedwithme search not returning folders shared with the user
2026-01-12 15:33:34 -05:00
Nick Richmond
53aa5e8f7f MetricsDrilldown: Remove exploreMetricsRelatedLogs feature toggle (#116090)
chore: remove unused exploreMetricsRelatedLogs feature toggle
2026-01-12 12:52:40 -05:00
Ida Štambuk
69bf3068b3 Dashboards: Never show scopes variables (#116132) 2026-01-12 18:52:23 +01:00
Will Assis
1263a3d364 unified-storage: HappyPath and notifier tests + couple of bugfixes (#116087)
* unified-storage: couple of bugfixes and enable HappyPath and notifier sqlkv tests
2026-01-12 12:17:41 -05:00
115 changed files with 1935 additions and 432 deletions

View File

@@ -336,7 +336,7 @@ rudderstack_data_plane_url =
rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
rudderstack_v3_sdk_url =
rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
rudderstack_config_url =
@@ -2079,8 +2079,14 @@ enable =
# To enable features by default, set `Expression: "true"` in:
# https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go
# The feature_toggles section supports feature flags of a number of types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
#
# feature1 = true
# feature2 = false
# feature3 = "foobar"
# feature4 = 1.5
# feature5 = { "foo": "bar" }
[feature_toggles.openfeature]
# This is EXPERIMENTAL. Please, do not use this section

View File

@@ -323,7 +323,7 @@
;rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
;rudderstack_v3_sdk_url =
;rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
;rudderstack_config_url =
@@ -1913,7 +1913,7 @@ default_datasource_uid =
# client_queue_max_size is the maximum size in bytes of the client queue
# for Live connections. Defaults to 4MB.
;client_queue_max_size =
;client_queue_max_size =
#################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer]
@@ -1996,9 +1996,14 @@ default_datasource_uid =
;enable = feature1,feature2
# The feature_toggles section supports feature flags of a number of types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
;feature1 = true
;feature2 = false
;feature3 = "foobar"
;feature4 = 1.5
;feature5 = { "foo": "bar" }
[date_formats]
# For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/

View File

@@ -2836,9 +2836,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../
Keys of features to enable, separated by space.
#### `FEATURE_TOGGLE_NAME = false`
#### `FEATURE_NAME = <value>`
Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`.
Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details.
For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
<hr>

View File

@@ -1156,11 +1156,6 @@
"count": 2
}
},
"public/app/core/config.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"public/app/core/navigation/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1

View File

@@ -622,10 +622,6 @@ export interface FeatureToggles {
*/
exploreLogsAggregatedMetrics?: boolean;
/**
* Used in Logs Drilldown to limit the time range
*/
exploreLogsLimitedTimeRange?: boolean;
/**
* Enables the gRPC client to authenticate with the App Platform by using ID & access tokens
*/
appPlatformGrpcClientAuth?: boolean;
@@ -695,10 +691,6 @@ export interface FeatureToggles {
*/
passwordlessMagicLinkAuthentication?: boolean;
/**
* Display Related Logs in Grafana Metrics Drilldown
*/
exploreMetricsRelatedLogs?: boolean;
/**
* Adds support for quotes and special characters in label values for Prometheus queries
*/
prometheusSpecialCharsInLabelValues?: boolean;

View File

@@ -14,6 +14,8 @@ export type Props = React.ComponentProps<typeof TextArea> & {
isConfigured: boolean;
/** Called when the user clicks on the "Reset" button in order to clear the secret */
onReset: () => void;
/** If true, the text area will grow to fill available width. */
grow?: boolean;
};
export const CONFIGURED_TEXT = 'configured';
@@ -35,11 +37,11 @@ const getStyles = (theme: GrafanaTheme2) => {
*
* https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs
*/
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => {
export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => {
const styles = useStyles2(getStyles);
return (
<Stack>
<Box>
<Box grow={grow ? 1 : undefined}>
{!isConfigured && <TextArea {...props} />}
{isConfigured && (
<TextArea

View File

@@ -106,10 +106,10 @@ describe('VizTooltipFooter', () => {
</MemoryRouter>
);
const onForButton = screen.getByRole('button', { name: /Apply as filter/i });
const onForButton = screen.getByTestId(selectors.components.VizTooltipFooter.buttons.apply);
expect(onForButton).toBeInTheDocument();
const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i });
const onOutButton = screen.getByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse);
expect(onOutButton).toBeInTheDocument();
await userEvent.click(onForButton);

View File

@@ -127,25 +127,19 @@ export const VizTooltipFooter = ({
variant="secondary"
size="sm"
onClick={filterByGroupedLabels.onFilterForGroupedLabels}
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
>
<Trans
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter"
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
>
Apply as filter
</Trans>
<Trans i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter">Filter on this value</Trans>
</Button>
<Button
icon="filter"
variant="secondary"
size="sm"
onClick={filterByGroupedLabels.onFilterOutGroupedLabels}
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
>
<Trans
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter"
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
>
Apply as inverse filter
<Trans i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter">
Filter out this value
</Trans>
</Button>
</Stack>

View File

@@ -552,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
// gets dashboards that the user was granted read access to
permissions := user.GetPermissions()
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
folderPermissions := permissions[dashboards.ActionFoldersRead]
dashboardUids := make([]string, 0)
sharedDashboards := make([]string, 0)
@@ -562,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
}
for _, folderPermission := range folderPermissions {
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
dashboardUids = append(dashboardUids, folderUid)
}
}
}
if len(dashboardUids) == 0 {
return sharedDashboards, nil
@@ -572,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
return sharedDashboards, err
}
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Federated: []*resourcepb.ResourceKey{folderKey},
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Options: &resourcepb.ListOptions{
Key: key,
Fields: []*resourcepb.Requirement{{
@@ -610,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
// only folders the user has access to will be returned here
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
folderSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(allFolders)),
@@ -628,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}},
},
}
// only folders the user has access to will be returned here
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
if err != nil {
return sharedDashboards, err

View File

@@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("publicfolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
@@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
// first call gets all dashboards user has permission for
firstCall := mockClient.MockCalls[0]
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
// verify federated field is set to include folders
assert.NotNil(t, firstCall.Federated)
assert.Equal(t, 1, len(firstCall.Federated))
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
// second call gets folders associated with the previous dashboards
secondCall := mockClient.MockCalls[1]
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
// permission to read
thirdCall := mockClient.MockCalls[2]
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
resp := rr.Result()
defer func() {

View File

@@ -133,7 +133,11 @@ type FeatureFlag struct {
Stage FeatureFlagStage `json:"stage,omitempty"`
Owner codeowner `json:"-"` // Owner person or team that owns this feature flag
// CEL-GO expression. Using the value "true" will mean this is on by default
// Expression defined by the feature_toggles configuration.
// Supports multiple types including boolean, string, integer, float,
// and structured values following the OpenFeature specification.
// Using the value "true" means the feature flag is enabled by default,
// Using the value "1.0" means the default value of the feature flag is 1.0
Expression string `json:"expression,omitempty"`
// Special behavior properties

View File

@@ -8,6 +8,7 @@ import (
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/open-feature/go-sdk/openfeature"
@@ -26,7 +27,7 @@ type OpenFeatureConfig struct {
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
HTTPClient *http.Client
// StaticFlags are the feature flags to use with static provider
StaticFlags map[string]bool
StaticFlags map[string]memprovider.InMemoryFlag
// TargetingKey is used for evaluation context
TargetingKey string
// ContextAttrs are additional attributes for evaluation context
@@ -100,7 +101,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
func createProvider(
providerType string,
u *url.URL,
staticFlags map[string]bool,
staticFlags map[string]memprovider.InMemoryFlag,
httpClient *http.Client,
) (openfeature.FeatureProvider, error) {
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
@@ -117,7 +118,7 @@ func createProvider(
}
}
return newStaticProvider(staticFlags)
return newStaticProvider(staticFlags, standardFeatureFlags)
}
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {

View File

@@ -1031,13 +1031,6 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "exploreLogsLimitedTimeRange",
Description: "Used in Logs Drilldown to limit the time range",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "appPlatformGrpcClientAuth",
Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens",
@@ -1148,14 +1141,6 @@ var (
Owner: identityAccessTeam,
HideFromDocs: true,
},
{
Name: "exploreMetricsRelatedLogs",
Description: "Display Related Logs in Grafana Metrics Drilldown",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityMetricsSquad,
FrontendOnly: true,
HideFromDocs: false,
},
{
Name: "prometheusSpecialCharsInLabelValues",
Description: "Adds support for quotes and special characters in label values for Prometheus queries",

View File

@@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
}
mgmt.warnings[key] = "unknown flag in config"
}
mgmt.startup[key] = val
mgmt.startup[key] = val.Variants[val.DefaultVariant] == true
}
// update the values

View File

@@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
}
staticProvider, err := newStaticProvider(staticFlags)
staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags)
if err != nil {
return nil, fmt.Errorf("failed to create static provider: %w", err)
}

View File

@@ -1,8 +1,13 @@
package featuremgmt
import (
"fmt"
"maps"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/setting"
)
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
@@ -28,37 +33,21 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
return keys, nil
}
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
// Parse and add standard flags
for _, flag := range standardFlags {
inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression)
if err != nil {
return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err)
}
flags[flag.Name] = inMemFlag
}
// Add flags from config.ini file
for name, value := range confFlags {
flags[name] = createInMemoryFlag(name, value)
}
// Add standard flags
for _, flag := range standardFeatureFlags {
if _, exists := flags[flag.Name]; !exists {
enabled := flag.Expression == "true"
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
}
}
maps.Copy(flags, confFlags)
return newInMemoryBulkProvider(flags), nil
}
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
variant := "disabled"
if enabled {
variant = "enabled"
}
return memprovider.InMemoryFlag{
Key: name,
DefaultVariant: variant,
Variants: map[string]interface{}{
"enabled": true,
"disabled": false,
},
}
}

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/open-feature/go-sdk/openfeature"
"github.com/stretchr/testify/assert"
@@ -93,3 +94,144 @@ ABCD = true
enabledFeatureManager := mgr.GetEnabled(ctx)
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
}
func Test_StaticProvider_TypedFlags(t *testing.T) {
tests := []struct {
flags FeatureFlag
defaultValue any
expectedValue any
}{
{
flags: FeatureFlag{
Name: "Flag",
Expression: "true",
},
defaultValue: false,
expectedValue: true,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1.0",
},
defaultValue: 0.0,
expectedValue: 1.0,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "blue",
},
defaultValue: "red",
expectedValue: "blue",
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1",
},
defaultValue: int64(0),
expectedValue: int64(1),
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: `{ "foo": "bar" }`,
},
expectedValue: map[string]any{"foo": "bar"},
},
}
for _, tt := range tests {
provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags})
assert.NoError(t, err)
var result any
switch tt.expectedValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.expectedValue, result)
}
}
func Test_StaticProvider_ConfigOverride(t *testing.T) {
tests := []struct {
name string
originalValue string
configValue any
}{
{
name: "bool",
originalValue: "false",
configValue: true,
},
{
name: "int",
originalValue: "0",
configValue: int64(1),
},
{
name: "float",
originalValue: "0.0",
configValue: 1.0,
},
{
name: "string",
originalValue: "foo",
configValue: "bar",
},
{
name: "structure",
originalValue: "{}",
configValue: make(map[string]any),
},
}
for _, tt := range tests {
configFlags, standardFlags := makeFlags(tt)
provider, err := newStaticProvider(configFlags, standardFlags)
assert.NoError(t, err)
var result any
switch tt.configValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.configValue, result)
}
}
func makeFlags(tt struct {
name string
originalValue string
configValue any
}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) {
orig := FeatureFlag{
Name: tt.name,
Expression: tt.originalValue,
}
config := map[string]memprovider.InMemoryFlag{
tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue),
}
return config, []FeatureFlag{orig}
}

View File

@@ -142,7 +142,6 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
@@ -159,7 +158,6 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
enableSCIM,preview,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
142 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
143 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
144 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true
exploreLogsLimitedTimeRange experimental @grafana/observability-logs false false true
145 appPlatformGrpcClientAuth experimental @grafana/identity-access-team false false false
146 groupAttributeSync privatePreview @grafana/identity-access-team false false false
147 alertingQueryAndExpressionsStepMode GA @grafana/alerting-squad false false true
158 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
159 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false
160 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
161 prometheusSpecialCharsInLabelValues experimental @grafana/oss-big-tent false false true
162 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
163 enableSCIM preview @grafana/identity-access-team false false false

View File

@@ -1382,7 +1382,8 @@
"metadata": {
"name": "exploreLogsLimitedTimeRange",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-08-29T13:55:59Z"
"creationTimestamp": "2024-08-29T13:55:59Z",
"deletionTimestamp": "2026-01-12T22:18:14Z"
},
"spec": {
"description": "Used in Logs Drilldown to limit the time range",
@@ -1408,7 +1409,8 @@
"metadata": {
"name": "exploreMetricsRelatedLogs",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-11-05T16:28:43Z"
"creationTimestamp": "2024-11-05T16:28:43Z",
"deletionTimestamp": "2026-01-09T22:14:53Z"
},
"spec": {
"description": "Display Related Logs in Grafana Metrics Drilldown",

View File

@@ -190,9 +190,6 @@ func verifyFlagsConfiguration(t *testing.T) {
if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" {
t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name)
}
if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" {
t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name)
}
// Check camel case names
if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] {
invalidNames = append(invalidNames, flag.Name)

View File

@@ -10,6 +10,7 @@ import (
"testing"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
@@ -378,8 +379,10 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) {
err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{
ProviderType: setting.StaticProviderType,
StaticFlags: map[string]bool{
featuremgmt.FlagPluginsAutoUpdate: flagValue,
StaticFlags: map[string]memprovider.InMemoryFlag{
featuremgmt.FlagPluginsAutoUpdate: {
Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue},
},
},
})
require.NoError(t, err)

View File

@@ -1,13 +1,20 @@
package setting
import (
"encoding/json"
"math"
"strconv"
"gopkg.in/ini.v1"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/util"
)
// DefaultVariantName a placeholder name for config-based Feature Flags
const DefaultVariantName = "default"
// Deprecated: should use `featuremgmt.FeatureToggles`
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
section := iniFile.Section("feature_toggles")
@@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
if err != nil {
return err
}
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
// nolint:staticcheck
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
cfg.IsFeatureToggleEnabled = func(key string) bool {
toggle, ok := toggles[key]
if !ok {
return false
}
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
return value && ok
}
return nil
}
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
featureToggles := make(map[string]bool, 10)
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
// parse the comma separated list in `enable`.
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
for _, feature := range util.SplitString(featuresTogglesStr) {
featureToggles[feature] = true
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
}
// read all other settings under [feature_toggles]. If a toggle is
@@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
continue
}
b, err := strconv.ParseBool(v.Value())
b, err := ParseFlag(v.Name(), v.Value())
if err != nil {
return featureToggles, err
}
@@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
}
return featureToggles, nil
}
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
var structure map[string]any
if integer, err := strconv.Atoi(value); err == nil {
return NewInMemoryFlag(name, integer), nil
}
if float, err := strconv.ParseFloat(value, 64); err == nil {
return NewInMemoryFlag(name, float), nil
}
if err := json.Unmarshal([]byte(value), &structure); err == nil {
return NewInMemoryFlag(name, structure), nil
}
if boolean, err := strconv.ParseBool(value); err == nil {
return NewInMemoryFlag(name, boolean), nil
}
return NewInMemoryFlag(name, value), nil
}
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
}
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
var res = map[string]string{}
for k, v := range m {
res[k] = serializeFlagValue(v)
}
return res
}
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
value := flag.Variants[flag.DefaultVariant]
switch castedValue := value.(type) {
case bool:
return strconv.FormatBool(castedValue)
case int64:
return strconv.FormatInt(castedValue, 10)
case float64:
// handle cases with a single or no zeros after the decimal point
if math.Trunc(castedValue) == castedValue {
return strconv.FormatFloat(castedValue, 'f', 1, 64)
}
return strconv.FormatFloat(castedValue, 'g', -1, 64)
case string:
return castedValue
default:
val, _ := json.Marshal(value)
return string(val)
}
}

View File

@@ -1,9 +1,11 @@
package setting
import (
"strconv"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
@@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) {
testCases := []struct {
name string
conf map[string]string
err error
expectedToggles map[string]bool
expectedToggles map[string]memprovider.InMemoryFlag
}{
{
name: "can parse feature toggles passed in the `enable` array",
conf: map[string]string{
"enable": "feature1,feature2",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
},
},
{
@@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature3": "true",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
"feature3": true,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
"feature3": NewInMemoryFlag("feature3", true),
},
},
{
@@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature2": "false",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": false,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", false),
},
},
{
name: "invalid boolean value should return syntax error",
name: "feature flags of different types are handled correctly",
conf: map[string]string{
"enable": "feature1,feature2",
"feature2": "invalid",
"feature1": "1", "feature2": "1.0",
"feature3": `{"foo":"bar"}`, "feature4": "bar",
"feature5": "t", "feature6": "T",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", 1),
"feature2": NewInMemoryFlag("feature2", 1.0),
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
"feature4": NewInMemoryFlag("feature4", "bar"),
"feature5": NewInMemoryFlag("feature5", true),
"feature6": NewInMemoryFlag("feature6", true),
},
expectedToggles: map[string]bool{},
err: strconv.ErrSyntax,
},
}
@@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) {
}
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
require.ErrorIs(t, err, tc.err)
require.NoError(t, err)
if err == nil {
for k, v := range featureToggles {
require.Equal(t, tc.expectedToggles[k], v, tc.name)
}
for k, v := range featureToggles {
toggle := tc.expectedToggles[k]
require.Equal(t, toggle, v, tc.name)
}
}
}
func TestFlagValueSerialization(t *testing.T) {
testCases := []memprovider.InMemoryFlag{
NewInMemoryFlag("int", 1),
NewInMemoryFlag("1.0f", 1.0),
NewInMemoryFlag("1.01f", 1.01),
NewInMemoryFlag("1.10f", 1.10),
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
NewInMemoryFlag("string", "bar"),
NewInMemoryFlag("true", true),
NewInMemoryFlag("false", false),
}
for _, tt := range testCases {
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
assert.NoError(t, err)
if diff := cmp.Diff(tt, deserialized); diff != "" {
t.Errorf("(-want, +got) = %v", diff)
}
}
}

View File

@@ -78,13 +78,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
cache := gocache.New(cacheTTL, cacheCleanupInterval)
events := make(chan Event, opts.BufferSize)
initialRV, err := n.lastEventResourceVersion(ctx)
lastRV, err := n.lastEventResourceVersion(ctx)
if errors.Is(err, ErrNotFound) {
initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning
lastRV = 0 // No events yet, start from the beginning
} else if err != nil {
n.log.Error("Failed to get last event resource version", "error", err)
}
lastRV := initialRV + 1 // We want to start watching from the next event
lastRV = lastRV + 1 // We want to start watching from the next event
go func() {
defer close(events)
@@ -110,7 +110,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
}
// Skip old events lower than the requested resource version
if evt.ResourceVersion <= initialRV {
if evt.ResourceVersion < lastRV {
continue
}

View File

@@ -25,7 +25,6 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
return notifier, eventStore
}
// nolint:unused
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
@@ -60,8 +59,7 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin
func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
}
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -112,8 +110,7 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not
func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
}
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -167,8 +164,7 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier,
func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
}
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -209,8 +205,7 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti
func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
}
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -284,8 +279,7 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti
func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
}
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -351,8 +345,7 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti
func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
}
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -398,8 +391,7 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not
func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
}
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {

View File

@@ -346,7 +346,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
return 0, fmt.Errorf("failed to write data: %w", err)
}
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(rv)
rv = rvmanager.SnowflakeFromRv(rv)
dataKey.ResourceVersion = rv
} else {
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
if err != nil {

View File

@@ -9,7 +9,6 @@ import (
"testing"
"time"
"github.com/bwmarrin/snowflake"
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
@@ -82,6 +81,12 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac
kvbackend, db := newKvBackend(t.Context())
sqlbackend, _ := newSqlBackend(t.Context())
// Skip on SQLite due to concurrency limitations
if db.DriverName() == "sqlite3" {
t.Skip("Skipping concurrent operations stress test on SQLite")
}
tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db)
})
}
@@ -187,13 +192,30 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
// verifyKeyPath is a helper function to verify key_path generation
func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) {
// For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format
// but key_path stores snowflake RV, so convert to snowflake
// For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format
isSqlBackend := strings.Contains(key.Namespace, "-sql")
var keyPathRV int64
if isSqlBackend {
// Convert microsecond RV to snowflake for key_path construction
keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion)
} else {
// KV backend already provides snowflake RV
keyPathRV = resourceVersion
}
// Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder
expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder)
var query string
if db.DriverName() == "postgres" {
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3"
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1"
} else {
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?"
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?"
}
rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion)
rows, err := db.QueryContext(ctx, query, expectedKeyPath)
require.NoError(t, err)
require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table")
@@ -220,10 +242,6 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource
// Verify action suffix
require.Contains(t, keyPath, fmt.Sprintf("~%s~", action))
// Verify snowflake calculation
expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000)
require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), "actual RV: %d", actualRV)
// Verify folder if specified
if expectedFolder != "" {
require.Equal(t, expectedFolder, actualFolder)
@@ -492,10 +510,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
}
// Validate previous_resource_version
// For KV backend operations, resource versions are stored as snowflake format
// but expectedPrevRV is in microsecond format, so we need to use IsRvEqual for comparison
// For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend)
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
if strings.Contains(record.Namespace, "-kv") {
require.True(t, rvmanager.IsRvEqual(record.PreviousResourceVersion, expectedPrevRV),
require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion),
"Previous resource version should match (KV backend snowflake format)")
} else {
require.Equal(t, expectedPrevRV, record.PreviousResourceVersion)
@@ -505,9 +523,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
require.Equal(t, expectedGeneration, record.Generation)
// Validate resource_version
// For KV backend operations, resource versions are stored as snowflake format
// For KV backend operations, expectedRV is now in snowflake format (returned by KV backend)
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
if strings.Contains(record.Namespace, "-kv") {
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
"Resource version should match (KV backend snowflake format)")
} else {
require.Equal(t, expectedRV, record.ResourceVersion)
@@ -574,7 +593,7 @@ func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources
// Resource version should match the expected version for test-resource-3 (updated version)
expectedRV := resourceVersions[2][1] // test-resource-3's update version
if strings.Contains(namespace, "-kv") {
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
"Resource version should match (KV backend snowflake format)")
} else {
require.Equal(t, expectedRV, record.ResourceVersion)
@@ -625,9 +644,16 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res
// The resource_version table should contain the latest RV for the group+resource
// It might be slightly higher due to RV manager operations, so check it's at least our max
require.GreaterOrEqual(t, record.ResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
// But it shouldn't be too much higher (within a reasonable range)
require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected")
// For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format
// Use IsRvEqual for proper comparison between different RV formats
isKvBackend := strings.Contains(namespace, "-kv")
recordResourceVersion := record.ResourceVersion
if isKvBackend {
recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion)
}
require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable")
require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
}
// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight)
@@ -666,11 +692,6 @@ func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource
// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends
func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
// Skip on SQLite due to concurrency limitations
if db.DriverName() == "sqlite3" {
t.Skip("Skipping concurrent operations stress test on SQLite")
}
ctx := testutil.NewDefaultTestContext(t)
// Create storage servers from both backends

View File

@@ -38,7 +38,6 @@ func TestBadgerKVStorageBackend(t *testing.T) {
func TestSQLKVStorageBackend(t *testing.T) {
skipTests := map[string]bool{
TestHappyPath: true,
TestWatchWriteEvents: true,
TestList: true,
TestBlobSupport: true,
@@ -51,21 +50,24 @@ func TestSQLKVStorageBackend(t *testing.T) {
TestGetResourceLastImportTime: true,
TestOptimisticLocking: true,
}
// without RvManager
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, false)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-test",
SkipTests: skipTests,
t.Run("Without RvManager", func(t *testing.T) {
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, false)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-test",
SkipTests: skipTests,
})
})
// with RvManager
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, true)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-withrvmanager-test",
SkipTests: skipTests,
t.Run("With RvManager", func(t *testing.T) {
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, true)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-withrvmanager-test",
SkipTests: skipTests,
})
})
}

View File

@@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) {
"value": true,
"key":"`+flag+`",
"reason":"static provider evaluation result",
"variant":"enabled"}`, string(rsp.Body))
"variant":"default"}`, string(rsp.Body))
})
}

View File

@@ -15,7 +15,10 @@ import (
func TestMain(m *testing.M) {
// make sure we don't leak goroutines after tests in this package have
// finished, which means we haven't leaked contexts either
goleak.VerifyTestMain(m)
// (Except for goroutines running specific functions. If possible we should fix this.)
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"),
)
}
func TestTestContextFunc(t *testing.T) {

View File

@@ -11,6 +11,7 @@ import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
import { getState } from 'app/store/store';
import { ThunkDispatch } from 'app/types/store';
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
@@ -19,6 +20,26 @@ import { refetchChildren } from '../../../../features/browse-dashboards/state/ac
import { handleError } from '../../../utils';
import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded';
const handleProvisioningFormError = (e: unknown, dispatch: ThunkDispatch, title: string) => {
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
const statusError: Status = e.error.data;
dispatch(notifyApp(createErrorNotification(title, new Error(statusError.message || 'Unknown error'))));
return;
}
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification(title)));
}
return;
}
}
handleError(e, dispatch, title);
};
export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
endpoints: {
listJob: {
@@ -37,6 +58,17 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
}),
onCacheEntryAdded: createOnCacheEntryAdded<RepositorySpec, RepositoryStatus>('repositories'),
},
listConnection: {
providesTags: (result) =>
result
? [
{ type: 'Connection', id: 'LIST' },
...result.items
.map((connection) => ({ type: 'Connection' as const, id: connection.metadata?.name }))
.filter(Boolean),
]
: [{ type: 'Connection', id: 'LIST' }],
},
deleteRepository: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
@@ -104,34 +136,7 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
try {
await queryFulfilled;
} catch (e) {
// Handle special cases first
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
// Handle Status error responses (Kubernetes style)
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
const statusError: Status = e.error.data;
dispatch(
notifyApp(
createErrorNotification(
'Error validating repository',
new Error(statusError.message || 'Unknown error')
)
)
);
return;
}
// Handle TestResults error responses with field errors
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
// Only show notification if there are errors that don't have a field, field errors are handled by the form
if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification('Error validating repository')));
}
return;
}
}
// For all other cases, use handleError
handleError(e, dispatch, 'Error validating repository');
handleProvisioningFormError(e, dispatch, 'Error validating repository');
}
},
},
@@ -240,6 +245,70 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
}
},
},
createConnection: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(t('provisioning.connection-form.alert-connection-saved', 'Connection saved'))
)
);
} catch (e) {
handleProvisioningFormError(
e,
dispatch,
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
);
}
},
},
replaceConnection: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
t('provisioning.connection-form.alert-connection-updated', 'Connection updated')
)
)
);
} catch (e) {
handleProvisioningFormError(
e,
dispatch,
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
);
}
},
},
deleteConnection: {
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Connection', id: 'LIST' }]),
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
t('provisioning.connection-form.alert-connection-deleted', 'Connection deleted')
)
)
);
} catch (e) {
if (e instanceof Error) {
dispatch(
notifyApp(
createErrorNotification(
t('provisioning.connection-form.error-delete-connection', 'Failed to delete connection'),
e
)
)
);
}
}
},
},
},
});

View File

@@ -4,8 +4,8 @@ import * as React from 'react';
import SplitPane, { Split } from 'react-split-pane';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getDragStyles } from '@grafana/ui';
import { config } from 'app/core/config';
interface Props {
splitOrientation?: Split;

View File

@@ -1,6 +1,5 @@
import { PluginState } from '@grafana/data';
import { config, GrafanaBootConfig } from '@grafana/runtime';
export { config, type GrafanaBootConfig as Settings };
let grafanaConfig: GrafanaBootConfig = config;

View File

@@ -2,7 +2,7 @@ import deepEqual from 'fast-deep-equal';
import memoize from 'micro-memoize';
import { getLanguage } from '@grafana/i18n/internal';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual });

View File

@@ -1,8 +1,7 @@
import { getThemeById } from '@grafana/data/internal';
import { ThemeChangedEvent } from '@grafana/runtime';
import { config, ThemeChangedEvent } from '@grafana/runtime';
import { appEvents } from '../app_events';
import { config } from '../config';
import { contextSrv } from '../services/context_srv';
import { PreferencesService } from './PreferencesService';

View File

@@ -1,7 +1,7 @@
import { Navigate } from 'react-router-dom-v5-compat';
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types';
import { AccessControlAction } from 'app/types/accessControl';

View File

@@ -1,4 +1,4 @@
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs'];

View File

@@ -8,8 +8,8 @@ import {
ThresholdsMode,
isTimeSeriesFrames,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GraphThresholdsStyleMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';

View File

@@ -18,7 +18,7 @@ import {
standardTransformers,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
export const standardAnnotationSupport: AnnotationSupport = {
/**

View File

@@ -3,10 +3,9 @@ import { connect, ConnectedProps } from 'react-redux';
import { GrafanaEdition } from '@grafana/data/internal';
import { Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { Grid, TextLink, ToolbarButton } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { config } from 'app/core/config';
import { StoreState } from 'app/types/store';
import { isOpenSourceBuildOrUnlicenced } from '../admin/EnterpriseAuthFeaturesCard';

View File

@@ -2,8 +2,8 @@ import { ComponentType } from 'react';
import { DataLink, RegistryItem, Action } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/internal';
import { config } from '@grafana/runtime';
import { ColorDimensionConfig, ScaleDimensionConfig, DirectionDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
import { BackgroundConfig, Constraint, LineConfig, Placement } from 'app/plugins/panel/canvas/panelcfg.gen';
import { LineStyleConfig } from '../../plugins/panel/canvas/editor/LineStyleEditor';

View File

@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';

View File

@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';

View File

@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';

View File

@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';

View File

@@ -14,10 +14,10 @@ import {
ActionType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { config } from 'app/core/config';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions/context';
import {

View File

@@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import Selecto from 'selecto';
import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
@@ -17,7 +17,6 @@ import {
DirectionDimensionConfig,
} from '@grafana/schema';
import { Portal } from '@grafana/ui';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import {
getColorDimensionFromData,

View File

@@ -2,7 +2,7 @@ import InfiniteViewer from 'infinite-viewer';
import Moveable from 'moveable';
import Selecto from 'selecto';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors';
import {
CONNECTION_VERTEX_ID,

View File

@@ -18,6 +18,7 @@ import {
NewObjectAddedToCanvasEvent,
ObjectRemovedFromCanvasEvent,
ObjectsReorderedOnCanvasEvent,
RepeatsUpdatedEvent,
} from './shared';
export interface DashboardEditPaneState extends SceneObjectState {
@@ -87,6 +88,12 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
})
);
this._subs.add(
dashboard.subscribeToEvent(RepeatsUpdatedEvent, () => {
this.forceRender();
})
);
if (this.panelEditAction) {
this.performPanelEditAction(this.panelEditAction);
this.panelEditAction = undefined;

View File

@@ -57,12 +57,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const visibleChildren = useMemo(() => {
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
return isEditing
? children
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
}, [editableElement, isEditing]);
const outlineChildren = editableElement.getOutlineChildren?.(isEditing) ?? [];
const visibleChildren = isEditing
? outlineChildren
: outlineChildren.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -258,7 +256,6 @@ function getStyles(theme: GrafanaTheme2) {
}),
nodeButtonClone: css({
color: theme.colors.text.secondary,
cursor: 'not-allowed',
}),
outlineInput: css({
border: `1px solid ${theme.components.input.borderColor}`,

View File

@@ -84,6 +84,10 @@ export class ConditionalRenderingChangedEvent extends BusEventWithPayload<SceneO
static type = 'conditional-rendering-changed';
}
export class RepeatsUpdatedEvent extends BusEventWithPayload<SceneObject> {
static type = 'repeats-updated';
}
export interface DashboardEditActionEventPayload {
removedObject?: SceneObject;
addedObject?: SceneObject;

View File

@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { VariableHide } from '@grafana/data';
import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes';
import { DashboardScene } from './DashboardScene';
import { VariableControls } from './VariableControls';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
jest.mock('@grafana/runtime', () => {
const runtime = jest.requireActual('@grafana/runtime');
return {
...runtime,
config: {
...runtime.config,
featureToggles: {
dashboardNewLayouts: true,
},
},
};
});
describe('VariableControls', () => {
it('should not render scopes variable', () => {
const variables = [new ScopesVariable({})];
const dashboard = buildScene(variables);
dashboard.activate();
render(<VariableControls dashboard={dashboard} />);
expect(screen.queryByText('__scopes')).not.toBeInTheDocument();
});
it('should not render regular hidden variables', () => {
const hiddenVariable = new TextBoxVariable({
name: 'HiddenVar',
hide: VariableHide.hideVariable,
});
const variables = [hiddenVariable];
const dashboard = buildScene(variables);
dashboard.activate();
render(<VariableControls dashboard={dashboard} />);
expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument();
});
it('should render regular hidden variables in edit mode', async () => {
const hiddenVariable = new TextBoxVariable({
name: 'HiddenVar',
hide: VariableHide.hideVariable,
});
const variables = [hiddenVariable];
const dashboard = buildScene(variables);
dashboard.activate();
dashboard.setState({ isEditing: true });
render(<VariableControls dashboard={dashboard} />);
expect(await screen.findByText('HiddenVar')).toBeInTheDocument();
});
it('should not render variables hidden in controls menu in edit mode', async () => {
const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]);
dashboard.activate();
dashboard.setState({ isEditing: true });
render(<VariableControls dashboard={dashboard} />);
expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument();
});
});
function buildScene(variables: SceneVariable[] = []) {
const dashboard = new DashboardScene({
$variables: new SceneVariableSet({ variables }),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [],
}),
}),
});
return dashboard;
}

View File

@@ -39,8 +39,9 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
: variables.filter(
(v) =>
// used for scopes variables, should always be hidden
// if we're editing in dynamic dashboards, still shows hidden variable but greyed out
(isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
(!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
v.state.hide !== VariableHide.inControlsMenu
);

View File

@@ -14,7 +14,7 @@ import {
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -147,6 +147,7 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
this.setState({ repeatedPanels, repeatedConditionalRendering });
this._prevRepeatValues = values;
this.publishEvent(new RepeatsUpdatedEvent(this), true);
}
public getPanelCount() {

View File

@@ -17,7 +17,7 @@ import {
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -219,6 +219,7 @@ export class DashboardGridItem
}
this._prevRepeatValues = values;
this.publishEvent(new RepeatsUpdatedEvent(this), true);
}
public handleVariableName() {

View File

@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { config } from '../../../../core/config';
import { grantUserPermissions } from '../../../alerting/unified/mocks';
import { DashboardScene, DashboardSceneState } from '../../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';

View File

@@ -1,8 +1,8 @@
import * as React from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { SharePublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard';
import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';

View File

@@ -4,7 +4,7 @@ const mockPushMeasurement = jest.fn();
import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor';
jest.mock('app/core/config', () => ({
jest.mock('@grafana/runtime', () => ({
config: {
grafanaJavascriptAgent: {
enabled: true,

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { faro } from '@grafana/faro-web-sdk';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { PanelLogEvents } from 'app/core/log_events';
interface Props {

View File

@@ -12,7 +12,7 @@ jest.mock('@grafana/faro-web-sdk', () => ({
},
}));
jest.mock('app/core/config', () => ({
jest.mock('@grafana/runtime', () => ({
config: {
grafanaJavascriptAgent: {
enabled: true,

View File

@@ -1,6 +1,6 @@
import { FieldConfigSource } from '@grafana/data';
import { faro } from '@grafana/faro-web-sdk';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events';
interface PanelLogInfo {

View File

@@ -1,7 +1,7 @@
import { config } from '@grafana/runtime';
import { DashboardRoutes } from 'app/types/dashboard';
import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport';
import { config } from '../../core/config';
import { RouteDescriptor } from '../../core/navigation/types';
export const getPublicDashboardRoutes = (): RouteDescriptor[] => {

View File

@@ -13,10 +13,9 @@ import {
dateTimeForTimeZone,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { appEvents } from 'app/core/app_events';
import { config } from 'app/core/config';
import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv';
import {
getCopiedTimeRange,

View File

@@ -2,8 +2,8 @@ import { each, map } from 'lodash';
import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { config } from '@grafana/runtime';
import { FieldConfigSource } from '@grafana/schema';
import { config } from 'app/core/config';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';

View File

@@ -6,7 +6,7 @@ import {
LoadingState,
PanelData,
} from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker';
import { getTimeSrv } from '../services/TimeSrv';

View File

@@ -6,9 +6,8 @@ import { useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { getBackendSrv } from '@grafana/runtime';
import { config, getBackendSrv } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { MediaType, PickerTabType, ResourceFolderName } from '../types';

View File

@@ -3,10 +3,9 @@ import { memo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { StoreState } from 'app/types/store';

View File

@@ -13,10 +13,9 @@ import {
EventBusSrv,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getTemplateSrv, PanelRenderer } from '@grafana/runtime';
import { config, getTemplateSrv, PanelRenderer } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { AdHocFilterItem, PanelChrome, withTheme2, Themeable2, PanelContextProvider } from '@grafana/ui';
import { config } from 'app/core/config';
import {
hasDeprecatedParentRowIndex,
migrateFromParentRowIndexToNestedFrames,

View File

@@ -22,7 +22,7 @@ import {
PluginExtensionPoints,
PluginExtensionTypes,
} from '@grafana/data';
import { usePluginLinks, usePluginComponents } from '@grafana/runtime';
import { usePluginLinks, usePluginComponents, config } from '@grafana/runtime';
import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants';
import { TraceViewPluginExtensionContext } from '../types/trace';
@@ -47,13 +47,6 @@ jest.mock('app/core/copy/appNotification', () => ({
})),
}));
// Mock config
jest.mock('../../../../../core/config', () => ({
config: {
feedbackLinksEnabled: false, // Default to false to avoid interference with tests
},
}));
// Mock navigator.clipboard
Object.assign(navigator, {
clipboard: {
@@ -127,6 +120,7 @@ describe('TracePageHeader test', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
config.feedbackLinksEnabled = false; // Default to false to avoid interference with tests
});
it('should render the new trace header', () => {
@@ -438,9 +432,7 @@ describe('TracePageHeader test', () => {
});
it('should render feedback button when feedbackLinksEnabled is true', () => {
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
config.feedbackLinksEnabled = true;
setup();
@@ -453,9 +445,7 @@ describe('TracePageHeader test', () => {
it('should display tooltip for feedback button', async () => {
const user = userEvent.setup();
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
config.feedbackLinksEnabled = true;
setup();
@@ -469,9 +459,7 @@ describe('TracePageHeader test', () => {
});
it('should render feedback button with correct styling and icon', () => {
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
config.feedbackLinksEnabled = true;
setup();

View File

@@ -26,7 +26,13 @@ import {
PluginExtensionPoints,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime';
import {
reportInteraction,
renderLimitedComponents,
usePluginComponents,
usePluginLinks,
config,
} from '@grafana/runtime';
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import {
@@ -46,7 +52,6 @@ import {
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { config } from '../../../../../core/config';
import { downloadTraceAsJson } from '../../../../inspector/utils/download';
import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';

View File

@@ -1,5 +1,5 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { EvalFunction } from '../alerting/state/alertDef';

View File

@@ -15,9 +15,8 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { Button, Spinner, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
import { dataFrameToLogsModel } from '../logs/logsModel';

View File

@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
/** @deprecated */
export const getPanelInspectorStyles = stylesFactory(() => {

View File

@@ -1,5 +1,5 @@
import { PanelPluginMeta, PluginState, unEscapeStringFromRegex } from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
export function getAllPanelPluginMeta(): PanelPluginMeta[] {
const allPanels = config.panels;

View File

@@ -11,6 +11,7 @@ import {
toDataFrame,
VisualizationSuggestionScore,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import {
BarGaugeDisplayMode,
BigValueColorMode,
@@ -20,7 +21,6 @@ import {
VizOrientation,
} from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
import { config } from 'app/core/config';
import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin';
import { pluginImporter } from 'app/features/plugins/importer/pluginImporter';

View File

@@ -3,7 +3,6 @@ import { from, forkJoin, timeout, lastValueFrom, catchError, of } from 'rxjs';
import { PanelPlugin, PluginError } from '@grafana/data';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { Settings } from 'app/core/config';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { StoreState, ThunkResult } from 'app/types/store';
@@ -301,7 +300,7 @@ export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> =
function updatePanels() {
return getBackendSrv()
.get('/api/frontend/settings')
.then((settings: Settings) => {
.then((settings) => {
config.panels = settings.panels;
});
}

View File

@@ -1,7 +1,7 @@
import { uniq } from 'lodash';
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
const profileRoutes: RouteDescriptor[] = [

View File

@@ -57,6 +57,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
const repositoryName = data?.metadata?.name;
const settings = useGetFrontendSettingsQuery();
const [submitData, request] = useCreateOrUpdateRepository(repositoryName);
const navigate = useNavigate();
const {
register,
handleSubmit,
@@ -77,7 +78,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
const isEdit = Boolean(repositoryName);
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [type, readOnly] = watch(['type', 'readOnly']);
const targetOptions = useMemo(() => getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]);
const isGitBased = isGitProvider(type);
@@ -104,17 +104,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
const localFields = type === 'local' ? getLocalProviderFields(type) : null;
const hasTokenInstructions = getHasTokenInstructions(type);
// TODO: this should be removed after 12.2 is released
useEffect(() => {
if (isGitBased && !data?.secure?.token) {
setTokenConfigured(false);
setError('token', {
type: 'manual',
message: `Enter your ${gitFields?.tokenConfig.label ?? 'access token'}`,
});
}
}, [data, gitFields, setTokenConfigured, setError, isGitBased]);
useEffect(() => {
if (request.isSuccess) {
const formData = getValues();
@@ -126,11 +115,9 @@ export function ConfigForm({ data }: ConfigFormProps) {
});
reset(formData);
setTimeout(() => {
navigate('/admin/provisioning');
}, 300);
setTimeout(() => navigate(PROVISIONING_URL), 300);
}
}, [request.isSuccess, reset, getValues, navigate, repositoryName]);
}, [request.isSuccess, reset, getValues, repositoryName, navigate]);
const onSubmit = async (form: RepositoryFormData) => {
setIsLoading(true);

View File

@@ -0,0 +1,277 @@
import { QueryStatus } from '@reduxjs/toolkit/query';
import { render, screen, waitFor } from 'test/test-utils';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
import { ConnectionForm } from './ConnectionForm';
jest.mock('../hooks/useCreateOrUpdateConnection', () => ({
useCreateOrUpdateConnection: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
const mockSubmitData = jest.fn();
const mockUseCreateOrUpdateConnection = useCreateOrUpdateConnection as jest.MockedFunction<
typeof useCreateOrUpdateConnection
>;
type MockRequestState = {
status: QueryStatus;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
error?: unknown;
reset: jest.Mock;
};
const createMockRequestState = (overrides: Partial<MockRequestState> = {}): MockRequestState => ({
status: QueryStatus.uninitialized,
isLoading: false,
isSuccess: false,
isError: false,
reset: jest.fn(),
...overrides,
});
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
metadata: { name: 'test-connection' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/12345678',
github: {
appID: '123456',
installationID: '12345678',
},
},
secure: {
privateKey: { name: 'configured' },
},
status: {
state: 'connected',
health: { healthy: true },
observedGeneration: 1,
},
...overrides,
});
interface SetupOptions {
data?: Connection;
requestState?: Partial<MockRequestState>;
}
function setup(options: SetupOptions = {}) {
const { data, requestState = {} } = options;
mockUseCreateOrUpdateConnection.mockReturnValue([
mockSubmitData,
createMockRequestState(requestState) as unknown as ReturnType<typeof useCreateOrUpdateConnection>[1],
]);
return {
mockSubmitData,
...render(<ConnectionForm data={data} />),
};
}
describe('ConnectionForm', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSubmitData.mockResolvedValue(undefined);
});
describe('Rendering - Create Mode', () => {
it('should render all form fields', () => {
setup();
expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument();
expect(screen.getByLabelText(/^GitHub App ID/)).toBeInTheDocument();
expect(screen.getByLabelText(/^GitHub Installation ID/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toBeInTheDocument();
});
it('should render Save button', () => {
setup();
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
});
it('should not render Delete button in create mode', () => {
setup();
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});
it('should have Provider field disabled', () => {
setup();
expect(screen.getByLabelText(/^Provider/)).toBeDisabled();
});
});
describe('Rendering - Edit Mode', () => {
it('should populate form fields with existing connection data', () => {
setup({ data: createMockConnection() });
expect(screen.getByLabelText(/^GitHub App ID/)).toHaveValue('123456');
expect(screen.getByLabelText(/^GitHub Installation ID/)).toHaveValue('12345678');
});
it('should render Delete button in edit mode', () => {
setup({ data: createMockConnection() });
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('should show configured state for private key', () => {
setup({ data: createMockConnection() });
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toHaveValue('configured');
});
});
describe('Form Validation', () => {
it('should show required error and not submit when fields are empty', async () => {
const { user, mockSubmitData } = setup();
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getAllByText('This field is required')).toHaveLength(3);
});
expect(mockSubmitData).not.toHaveBeenCalled();
});
});
describe('Form Submission - Create', () => {
it('should call submitData with correct data on valid submission', async () => {
const { user, mockSubmitData } = setup();
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockSubmitData).toHaveBeenCalledWith(
{
type: 'github',
github: {
appID: '123456',
installationID: '12345678',
},
},
'-----BEGIN RSA PRIVATE KEY-----'
);
});
});
});
describe('Form Submission - Edit', () => {
it('should allow submission without changing private key', async () => {
const { user, mockSubmitData } = setup({ data: createMockConnection() });
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockSubmitData).toHaveBeenCalledWith(
{
type: 'github',
github: {
appID: '123456',
installationID: '12345678',
},
},
'configured'
);
});
});
});
describe('Loading State', () => {
it('should disable Save button while loading', () => {
setup({ requestState: { isLoading: true } });
const saveButton = screen.getByRole('button', { name: /saving/i });
expect(saveButton).toBeDisabled();
});
it('should show "Saving..." text while loading', () => {
setup({ requestState: { isLoading: true } });
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should map API error for appID to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'appID', detail: 'Invalid App ID' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid App ID')).toBeInTheDocument();
});
});
it('should map API error for installationID to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'installationID', detail: 'Invalid Installation ID' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid Installation ID')).toBeInTheDocument();
});
});
it('should map API error for privateKey to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'secure.privateKey', detail: 'Invalid Private Key format' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), 'invalid-key');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid Private Key format')).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import { Button, Combobox, Field, Input, SecretTextArea, Stack } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { CONNECTIONS_URL } from '../constants';
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
import { ConnectionFormData } from '../types';
import { getConnectionFormErrors } from '../utils/getFormErrors';
import { DeleteConnectionButton } from './DeleteConnectionButton';
interface ConnectionFormProps {
data?: Connection;
}
const providerOptions = [{ value: 'github', label: 'GitHub' }];
export function ConnectionForm({ data }: ConnectionFormProps) {
const connectionName = data?.metadata?.name;
const isEdit = Boolean(connectionName);
const privateKey = data?.secure?.privateKey;
const [privateKeyConfigured, setPrivateKeyConfigured] = useState(Boolean(privateKey));
const [submitData, request] = useCreateOrUpdateConnection(connectionName);
const navigate = useNavigate();
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isDirty },
setValue,
getValues,
setError,
} = useForm<ConnectionFormData>({
defaultValues: {
type: data?.spec?.type || 'github',
appID: data?.spec?.github?.appID || '',
installationID: data?.spec?.github?.installationID || '',
privateKey: privateKey?.name || '',
},
});
useEffect(() => {
if (request.isSuccess) {
const formData = getValues();
reportInteraction('grafana_provisioning_connection_saved', {
connectionName: connectionName ?? 'unknown',
connectionType: formData.type,
});
reset(formData);
// use timeout to ensure the form resets before navigating
setTimeout(() => navigate(CONNECTIONS_URL), 300);
}
}, [request.isSuccess, reset, getValues, connectionName, navigate]);
const onSubmit = async (form: ConnectionFormData) => {
try {
const spec = {
type: form.type,
github: {
appID: form.appID,
installationID: form.installationID,
},
};
await submitData(spec, form.privateKey);
} catch (err) {
if (isFetchError(err)) {
const [field, errorMessage] = getConnectionFormErrors(err.data?.errors);
if (field && errorMessage) {
setError(field, errorMessage);
return;
}
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 700 }}>
<FormPrompt onDiscard={reset} confirmRedirect={isDirty} />
<Stack direction="column" gap={2}>
<Field
noMargin
htmlFor="type"
label={t('provisioning.connection-form.label-provider', 'Provider')}
description={t('provisioning.connection-form.description-provider', 'Select the provider type')}
>
<Controller
name="type"
control={control}
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
id="type"
disabled // TODO enable when other providers are supported
options={providerOptions}
onChange={(option) => onChange(option?.value)}
{...field}
/>
)}
/>
</Field>
<Field
noMargin
label={t('provisioning.connection-form.label-app-id', 'GitHub App ID')}
description={t('provisioning.connection-form.description-app-id', 'The ID of your GitHub App')}
invalid={!!errors.appID}
error={errors?.appID?.message}
required
>
<Input
id="appID"
{...register('appID', {
required: t('provisioning.connection-form.error-required', 'This field is required'),
})}
placeholder={t('provisioning.connection-form.placeholder-app-id', '123456')}
/>
</Field>
<Field
noMargin
label={t('provisioning.connection-form.label-installation-id', 'GitHub Installation ID')}
description={t(
'provisioning.connection-form.description-installation-id',
'The installation ID of your GitHub App'
)}
invalid={!!errors.installationID}
error={errors?.installationID?.message}
required
>
<Input
id="installationID"
{...register('installationID', {
required: t('provisioning.connection-form.error-required', 'This field is required'),
})}
placeholder={t('provisioning.connection-form.placeholder-installation-id', '12345678')}
/>
</Field>
<Field
noMargin
htmlFor="privateKey"
label={t('provisioning.connection-form.label-private-key', 'Private Key (PEM)')}
description={t(
'provisioning.connection-form.description-private-key',
'The private key for your GitHub App in PEM format'
)}
invalid={!!errors.privateKey}
error={errors?.privateKey?.message}
required={!isEdit}
>
<Controller
name="privateKey"
control={control}
rules={{
required: isEdit ? false : t('provisioning.connection-form.error-required', 'This field is required'),
}}
render={({ field: { ref, ...field } }) => (
<SecretTextArea
{...field}
id="privateKey"
placeholder={t(
'provisioning.connection-form.placeholder-private-key',
'-----BEGIN RSA PRIVATE KEY-----...'
)}
isConfigured={privateKeyConfigured}
onReset={() => {
setValue('privateKey', '');
setPrivateKeyConfigured(false);
}}
rows={8}
grow
/>
)}
/>
</Field>
<Stack gap={2}>
<Button type="submit" disabled={request.isLoading}>
{request.isLoading
? t('provisioning.connection-form.button-saving', 'Saving...')
: t('provisioning.connection-form.button-save', 'Save')}
</Button>
{connectionName && data && <DeleteConnectionButton name={connectionName} connection={data} />}
</Stack>
</Stack>
</form>
);
}

View File

@@ -0,0 +1,59 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useParams } from 'react-router-dom-v5-compat';
import { Trans, t } from '@grafana/i18n';
import { EmptyState, Text, TextLink } from '@grafana/ui';
import { useGetConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
import { Page } from 'app/core/components/Page/Page';
import { CONNECTIONS_URL } from '../constants';
import { ConnectionForm } from './ConnectionForm';
export default function ConnectionFormPage() {
const { name = '' } = useParams();
const isCreate = !name;
const query = useGetConnectionQuery(isCreate ? skipToken : { name });
//@ts-expect-error TODO add error types
const notFound = !isCreate && query.isError && query.error?.status === 404;
const pageTitle = isCreate
? t('provisioning.connection-form.page-title-create', 'Create connection')
: t('provisioning.connection-form.page-title-edit', 'Edit connection');
return (
<Page
navId="provisioning"
pageNav={{
text: pageTitle,
subTitle: t(
'provisioning.connection-form.page-subtitle',
'Configure a connection to authenticate with external providers'
),
parentItem: {
text: t('provisioning.connections.page-title', 'Connections'),
url: CONNECTIONS_URL,
},
}}
>
<Page.Contents isLoading={!isCreate && query.isLoading}>
{notFound ? (
<EmptyState message={t('provisioning.connection-form.not-found', 'Connection not found')} variant="not-found">
<Text element="p">
<Trans i18nKey="provisioning.connection-form.not-found-description">
The connection you are looking for does not exist.
</Trans>
</Text>
<TextLink href={CONNECTIONS_URL}>
<Trans i18nKey="provisioning.connection-form.back-to-connections">Back to connections</Trans>
</TextLink>
</EmptyState>
) : (
<ConnectionForm data={isCreate ? undefined : query.data} />
)}
</Page.Contents>
</Page>
);
}

View File

@@ -0,0 +1,165 @@
import { render, screen } from 'test/test-utils';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { ConnectionList } from './ConnectionList';
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
metadata: { name: 'test-connection' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/12345678',
github: {
appID: '123456',
installationID: '12345678',
},
},
status: {
state: 'connected',
health: { healthy: true },
observedGeneration: 1,
},
...overrides,
});
const mockConnections: Connection[] = [
createMockConnection({
metadata: { name: 'github-conn-1' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/103343308',
github: {
appID: '123456',
installationID: '103343308',
},
},
}),
createMockConnection({
metadata: { name: 'gitlab-conn-2' },
spec: { type: 'gitlab', url: 'https://gitlab.com/org2/repo2' },
}),
createMockConnection({
metadata: { name: 'another-github' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/987654321',
github: {
appID: '654321',
installationID: '987654321',
},
},
}),
];
function setup(items: Connection[] = mockConnections) {
return render(<ConnectionList items={items} />, { renderWithRouter: true });
}
describe('ConnectionList', () => {
describe('Rendering', () => {
it('should render search input with correct placeholder', () => {
setup();
expect(screen.getByPlaceholderText('Search connections')).toBeInTheDocument();
});
it('should render all connection items when no filter is applied', () => {
setup();
// Verify all 3 connections are displayed by checking for their URL links
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).toBeInTheDocument();
});
it('should render EmptyState when items array is empty', () => {
setup([]);
expect(screen.getByText('No connections configured')).toBeInTheDocument();
});
});
describe('Filtering', () => {
it('should filter connections by name', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'gitlab');
// Should show only gitlab connection
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).not.toBeInTheDocument();
});
it('should filter connections by provider type', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'github');
// Should show only github connections
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'https://gitlab.com/org2/repo2' })).not.toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).toBeInTheDocument();
});
it('should be case-insensitive', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'GITLAB');
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
});
it('should show EmptyState when filter matches nothing', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'nonexistent');
expect(screen.getByText('No results matching your query')).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).not.toBeInTheDocument();
});
it('should clear filter and show all items', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'gitlab');
// Filter applied
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).not.toBeInTheDocument();
// Clear the filter
await user.clear(searchInput);
// All items should be visible again
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import { t } from '@grafana/i18n';
import { EmptyState, FilterInput, Stack } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { ConnectionListItem } from './ConnectionListItem';
interface Props {
items: Connection[];
}
export function ConnectionList({ items }: Props) {
const [query, setQuery] = useState('');
const filteredItems = items.filter((item) => {
if (!query) {
return true;
}
const lowerQuery = query.toLowerCase();
const name = item.metadata?.name?.toLowerCase() ?? '';
const providerType = item.spec?.type?.toLowerCase() ?? '';
return name.includes(lowerQuery) || providerType.includes(lowerQuery);
});
const isEmpty = items.length === 0;
return (
<Stack direction={'column'} gap={3}>
<FilterInput
placeholder={t('provisioning.connections.search-placeholder', 'Search connections')}
value={query}
onChange={setQuery}
/>
<Stack direction={'column'} gap={2}>
{filteredItems.length ? (
filteredItems.map((item) => <ConnectionListItem key={item.metadata?.name} connection={item} />)
) : (
<EmptyState
variant={isEmpty ? 'completed' : 'not-found'}
message={
isEmpty
? t('provisioning.connections.no-connections', 'No connections configured')
: t('provisioning.connections.no-results', 'No results matching your query')
}
/>
)}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,49 @@
import { Trans } from '@grafana/i18n';
import { Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { RepoIcon } from '../Shared/RepoIcon';
import { RepoType } from '../Wizard/types';
import { CONNECTIONS_URL } from '../constants';
import { getRepositoryTypeConfigs } from '../utils/repositoryTypes';
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
interface Props {
connection: Connection;
}
export function ConnectionListItem({ connection }: Props) {
const { metadata, spec, status } = connection;
const name = metadata?.name ?? '';
const url = spec?.url;
const providerType: RepoType = spec?.type ?? 'github';
const repoConfig = getRepositoryTypeConfigs().find((config) => config.type === providerType);
return (
<Card noMargin key={name}>
<Card.Figure>
<RepoIcon type={providerType} />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center">
{repoConfig && <Text variant="h3">{`${repoConfig.label} app connection`}</Text>}
{status?.state && <ConnectionStatusBadge status={status} />}
</Stack>
</Card.Heading>
{url && (
<Card.Meta>
<TextLink external href={url}>
{url}
</TextLink>
</Card.Meta>
)}
<Card.Actions>
<LinkButton icon="eye" href={`${CONNECTIONS_URL}/${name}/edit`} variant="primary" size="md">
<Trans i18nKey="provisioning.connections.view">View</Trans>
</LinkButton>
</Card.Actions>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
import { t } from '@grafana/i18n';
import { Badge, IconName } from '@grafana/ui';
import { ConnectionStatus } from 'app/api/clients/provisioning/v0alpha1';
interface Props {
status: ConnectionStatus;
}
interface BadgeConfig {
color: 'green' | 'red' | 'darkgrey';
text: string;
icon: IconName;
}
function getBadgeConfig(status: ConnectionStatus): BadgeConfig {
switch (status.state) {
case 'connected':
return {
color: 'green',
text: t('provisioning.connections.status-connected', 'Connected'),
icon: 'check',
};
case 'disconnected':
return {
color: 'red',
text: t('provisioning.connections.status-disconnected', 'Disconnected'),
icon: 'times-circle',
};
default:
return {
color: 'darkgrey',
text: t('provisioning.connections.status-unknown', 'Unknown'),
icon: 'question-circle',
};
}
}
export function ConnectionStatusBadge({ status }: Props) {
const config = getBadgeConfig(status);
return <Badge color={config.color} text={config.text} icon={config.icon} />;
}

View File

@@ -0,0 +1,55 @@
import { t, Trans } from '@grafana/i18n';
import { Alert, EmptyState, LinkButton, Stack, Text } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { CONNECTIONS_URL } from '../constants';
import { useConnectionList } from '../hooks/useConnectionList';
import { getErrorMessage } from '../utils/httpUtils';
import { ConnectionList } from './ConnectionList';
export default function ConnectionsPage() {
const [items, isLoading, error] = useConnectionList();
const hasNoConnections = !isLoading && !error && items?.length === 0;
return (
<Page
navId="provisioning"
pageNav={{
text: t('provisioning.connections.page-title', 'Connections'),
subTitle: t('provisioning.connections.page-subtitle', 'View and manage your app connections'),
}}
actions={
<LinkButton variant="primary" href={`${CONNECTIONS_URL}/new`}>
<Trans i18nKey="provisioning.connections.add-connection">Add connection</Trans>
</LinkButton>
}
>
<Page.Contents isLoading={isLoading}>
<Stack direction={'column'} gap={3}>
{!!error && (
<Alert severity="error" title={t('provisioning.connections.error-loading', 'Failed to load connections')}>
{getErrorMessage(error)}
</Alert>
)}
{hasNoConnections && (
<EmptyState
variant="call-to-action"
message={t('provisioning.connections.no-connections', 'No connections configured')}
>
<Text element="p">
{t(
'provisioning.connections.no-connections-message',
'Add a connection to authenticate with external providers'
)}
</Text>
</EmptyState>
)}
{!!items?.length && <ConnectionList items={items} />}
</Stack>
</Page.Contents>
</Page>
);
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button } from '@grafana/ui';
import { Connection, useDeleteConnectionMutation } from 'app/api/clients/provisioning/v0alpha1';
import { appEvents } from 'app/core/app_events';
import { ShowConfirmModalEvent } from 'app/types/events';
import { CONNECTIONS_URL } from '../constants';
interface Props {
name: string;
connection: Connection;
}
export function DeleteConnectionButton({ name, connection }: Props) {
const navigate = useNavigate();
const [deleteConnection, deleteRequest] = useDeleteConnectionMutation();
const onDelete = useCallback(async () => {
reportInteraction('grafana_provisioning_connection_deleted', {
connectionName: name,
connectionType: connection?.spec?.type ?? 'unknown',
});
await deleteConnection({ name });
navigate(CONNECTIONS_URL);
}, [deleteConnection, name, connection, navigate]);
const showDeleteModal = useCallback(() => {
appEvents.publish(
new ShowConfirmModalEvent({
title: t('provisioning.connections.delete-title', 'Delete connection'),
text: t(
'provisioning.connections.delete-confirm',
'Are you sure you want to delete this connection? This action cannot be undone.'
),
yesText: t('provisioning.connections.delete', 'Delete'),
noText: t('provisioning.connections.cancel', 'Cancel'),
yesButtonVariant: 'destructive',
onConfirm: onDelete,
})
);
}, [onDelete]);
return (
<Button variant="destructive" size="md" disabled={deleteRequest.isLoading} onClick={showDeleteModal}>
<Trans i18nKey="provisioning.connections.delete">Delete</Trans>
</Button>
);
}

View File

@@ -1,14 +1,16 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
import { Button, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
import {
Repository,
useDeleteRepositoryMutation,
useReplaceRepositoryMutation,
} from 'app/api/clients/provisioning/v0alpha1';
import { appEvents } from 'app/core/app_events';
import { ShowConfirmModalEvent } from 'app/types/events';
type DeleteAction = 'remove-resources' | 'keep-resources';
@@ -21,110 +23,102 @@ interface Props {
export function DeleteRepositoryButton({ name, repository, redirectTo }: Props) {
const [deleteRepository, deleteRequest] = useDeleteRepositoryMutation();
const [replaceRepository, replaceRequest] = useReplaceRepositoryMutation();
const [showModal, setShowModal] = useState(false);
const [selectedAction, setSelectedAction] = useState<DeleteAction>('remove-resources');
const navigate = useNavigate();
useEffect(() => {
if (deleteRequest.isSuccess) {
setShowModal(false);
const performDelete = useCallback(
async (deleteAction: DeleteAction) => {
if (deleteAction === 'keep-resources' && repository) {
const updatedRepository = {
...repository,
metadata: {
...repository.metadata,
finalizers: ['cleanup', 'release-orphan-resources'],
},
};
await replaceRepository({ name, repository: updatedRepository });
}
reportInteraction('grafana_provisioning_repository_deleted', {
repositoryName: name,
repositoryType: repository?.spec?.type ?? 'unknown',
deleteAction,
target: repository?.spec?.sync?.target ?? 'unknown',
workflows: repository?.spec?.workflows ?? [],
});
await deleteRepository({ name });
if (redirectTo) {
navigate(redirectTo);
}
}
}, [deleteRequest.isSuccess, redirectTo, navigate]);
},
[deleteRepository, replaceRepository, name, repository, redirectTo, navigate]
);
const onConfirm = useCallback(async () => {
if (selectedAction === 'keep-resources' && repository) {
const updatedRepository = {
...repository,
metadata: {
...repository.metadata,
finalizers: ['cleanup', 'release-orphan-resources'],
},
};
await replaceRepository({ name, repository: updatedRepository });
}
reportInteraction('grafana_provisioning_repository_deleted', {
repositoryName: name,
repositoryType: repository?.spec?.type ?? 'unknown',
deleteAction: selectedAction,
target: repository?.spec?.sync?.target ?? 'unknown',
workflows: repository?.spec?.workflows ?? [],
});
deleteRepository({ name });
}, [deleteRepository, replaceRepository, name, selectedAction, repository]);
const getConfirmationMessage = () => {
if (selectedAction === 'remove-resources') {
return t(
'provisioning.delete-repository-button.confirm-delete-with-resources',
'Are you sure you want to delete the repository configuration and all its resources?'
);
}
return t(
'provisioning.delete-repository-button.confirm-delete-keep-resources',
'Are you sure you want to delete the repository configuration but keep its resources?'
const showDeleteWithResourcesModal = useCallback(() => {
appEvents.publish(
new ShowConfirmModalEvent({
title: t(
'provisioning.delete-repository-button.title-delete-repository-and-resources',
'Delete repository configuration and resources'
),
text: t(
'provisioning.delete-repository-button.confirm-delete-with-resources',
'Are you sure you want to delete the repository configuration and all its resources?'
),
yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'),
noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'),
yesButtonVariant: 'destructive',
onConfirm: () => performDelete('remove-resources'),
})
);
};
}, [performDelete]);
const getModalTitle = () => {
if (selectedAction === 'remove-resources') {
return t(
'provisioning.delete-repository-button.title-delete-repository-and-resources',
'Delete repository configuration and resources'
);
}
return t(
'provisioning.delete-repository-button.title-delete-repository-only',
'Delete repository configuration only'
const showDeleteKeepResourcesModal = useCallback(() => {
appEvents.publish(
new ShowConfirmModalEvent({
title: t(
'provisioning.delete-repository-button.title-delete-repository-only',
'Delete repository configuration only'
),
text: t(
'provisioning.delete-repository-button.confirm-delete-keep-resources',
'Are you sure you want to delete the repository configuration but keep its resources?'
),
yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'),
noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'),
yesButtonVariant: 'destructive',
onConfirm: () => performDelete('keep-resources'),
})
);
};
}, [performDelete]);
const isLoading = deleteRequest.isLoading || replaceRequest.isLoading;
return (
<>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t(
'provisioning.delete-repository-button.delete-and-remove-resources',
'Delete and remove resources (default)'
)}
onClick={() => {
setSelectedAction('remove-resources');
setShowModal(true);
}}
/>
<Menu.Item
label={t('provisioning.delete-repository-button.delete-and-keep-resources', 'Delete and keep resources')}
onClick={() => {
setSelectedAction('keep-resources');
setShowModal(true);
}}
/>
</Menu>
}
>
<Button variant="destructive" disabled={isLoading}>
<Stack alignItems="center">
<Trans i18nKey="provisioning.delete-repository-button.delete">Delete</Trans>
<Icon name={'angle-down'} />
</Stack>
</Button>
</Dropdown>
<ConfirmModal
isOpen={showModal}
title={getModalTitle()}
body={getConfirmationMessage()}
confirmText={t('provisioning.delete-repository-button.button-delete', 'Delete')}
onConfirm={onConfirm}
onDismiss={() => setShowModal(false)}
/>
</>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t(
'provisioning.delete-repository-button.delete-and-remove-resources',
'Delete and remove resources (default)'
)}
onClick={showDeleteWithResourcesModal}
/>
<Menu.Item
label={t('provisioning.delete-repository-button.delete-and-keep-resources', 'Delete and keep resources')}
onClick={showDeleteKeepResourcesModal}
/>
</Menu>
}
>
<Button variant="destructive" disabled={isLoading}>
<Stack alignItems="center">
<Trans i18nKey="provisioning.delete-repository-button.delete">Delete</Trans>
<Icon name={'angle-down'} />
</Stack>
</Button>
</Dropdown>
);
}

View File

@@ -4,7 +4,7 @@ import { Badge, Button, LinkButton, Stack } from '@grafana/ui';
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
import { StatusBadge } from '../Shared/StatusBadge';
import { PROVISIONING_URL } from '../constants';
import { CONNECTIONS_URL, PROVISIONING_URL } from '../constants';
import { getRepoHrefForProvider } from '../utils/git';
import { getIsReadOnlyWorkflows } from '../utils/repository';
import { getRepositoryTypeConfig } from '../utils/repositoryTypes';
@@ -34,6 +34,9 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) {
</Button>
)}
<SyncRepository repository={repository} />
<LinkButton variant="secondary" icon="link" href={CONNECTIONS_URL}>
<Trans i18nKey="provisioning.repository-actions.connections">Connections</Trans>
</LinkButton>
<LinkButton
variant="secondary"
icon="cog"

View File

@@ -1,4 +1,5 @@
export const PROVISIONING_URL = '/admin/provisioning';
export const CONNECTIONS_URL = `${PROVISIONING_URL}/connections`;
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
export const UPGRADE_URL = 'https://grafana.com/profile/org/subscription';

View File

@@ -0,0 +1,17 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { ListConnectionApiArg, useListConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
// Sort connections alphabetically by name
export function useConnectionList(options: ListConnectionApiArg | typeof skipToken = {}) {
const query = useListConnectionQuery(options);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const nameA = a.metadata?.name ?? '';
const nameB = b.metadata?.name ?? '';
return collator.compare(nameA, nameB);
});
return [sortedItems, query.isLoading, query.error] as const;
}

View File

@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import {
Connection,
ConnectionSpec,
ConnectionSecure,
useCreateConnectionMutation,
useReplaceConnectionMutation,
} from 'app/api/clients/provisioning/v0alpha1';
export function useCreateOrUpdateConnection(name?: string) {
const [create, createRequest] = useCreateConnectionMutation();
const [update, updateRequest] = useReplaceConnectionMutation();
const updateOrCreate = useCallback(
async (data: ConnectionSpec, privateKey?: string) => {
const secure: ConnectionSecure | undefined = privateKey?.length
? { privateKey: { create: privateKey } }
: undefined;
const connection: Connection = {
metadata: name ? { name } : { generateName: 'c' },
spec: data,
secure,
};
if (name) {
return update({
name,
connection,
});
}
return create({ connection });
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}

View File

@@ -5,6 +5,7 @@ import { SelectableValue } from '@grafana/data';
import {
BitbucketRepositoryConfig,
ConnectionSpec,
GitHubRepositoryConfig,
GitLabRepositoryConfig,
GitRepositoryConfig,
@@ -51,6 +52,16 @@ export type RepositoryFormData = Omit<RepositorySpec, 'workflows' | RepositorySp
export type RepositorySettingsField = Path<RepositoryFormData>;
// Connection type definition - extracted from API client
export type ConnectionType = ConnectionSpec['type'];
export type ConnectionFormData = {
type: ConnectionSpec['type'];
appID: string;
installationID: string;
privateKey?: string;
};
// Section configuration
export interface RepositorySection {
name: string;

View File

@@ -3,7 +3,7 @@ import { Path } from 'react-hook-form';
import { ErrorDetails } from 'app/api/clients/provisioning/v0alpha1';
import { WizardFormData } from '../Wizard/types';
import { RepositoryFormData } from '../types';
import { ConnectionFormData, RepositoryFormData } from '../types';
export type RepositoryField = keyof WizardFormData['repository'];
export type RepositoryFormPath = `repository.${RepositoryField}` | 'repository.sync.intervalSeconds';
@@ -89,3 +89,20 @@ export const getConfigFormErrors = (errors?: ErrorDetails[]): ConfigFormErrorTup
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
};
// Connection form errors
export type ConnectionFormPath = Path<ConnectionFormData>;
export type ConnectionFormErrorTuple = GenericFormErrorTuple<ConnectionFormPath>;
export const getConnectionFormErrors = (errors?: ErrorDetails[]): ConnectionFormErrorTuple => {
const fieldMap: Record<string, ConnectionFormPath> = {
appID: 'appID',
installationID: 'installationID',
'github.appID': 'appID',
'github.installationID': 'installationID',
'secure.privateKey': 'privateKey',
privateKey: 'privateKey',
};
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
};

View File

@@ -3,7 +3,7 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { DashboardRoutes } from 'app/types/dashboard';
import { checkRequiredFeatures } from '../GettingStarted/features';
import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
export function getProvisioningRoutes(): RouteDescriptor[] {
if (!checkRequiredFeatures()) {
@@ -36,6 +36,26 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
)
),
},
{
path: CONNECTIONS_URL,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ConnectionsPage"*/ 'app/features/provisioning/Connection/ConnectionsPage')
),
},
{
path: `${CONNECTIONS_URL}/:name/edit`,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage')
),
},
{
path: `${CONNECTIONS_URL}/new`,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage')
),
},
{
path: `${CONNECT_URL}/:type`,
component: SafeDynamicImport(

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { Props, UsersActionBarUnconnected } from './UsersActionBar';
import { searchQueryChanged } from './state/reducers';

View File

@@ -4,8 +4,8 @@ import { Link } from 'react-router-dom-v5-compat';
import { SIGV4ConnectionConfig } from '@grafana/aws-sdk';
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Box, DataSourceHttpSettings, InlineField, InlineSwitch, Select, Text } from '@grafana/ui';
import { config } from 'app/core/config';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types';

View File

@@ -5,7 +5,7 @@ import { useEffectOnce, useToggle } from 'react-use';
import { GrafanaTheme2, PanelProps } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { config, TimeRangeUpdatedEvent } from '@grafana/runtime';
import {
Alert,
BigValue,
@@ -17,7 +17,6 @@ import {
ScrollContainer,
useStyles2,
} from '@grafana/ui';
import { config } from 'app/core/config';
import alertDef from 'app/features/alerting/state/alertDef';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';

View File

@@ -12,10 +12,10 @@ import {
PanelProps,
VizOrientation,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { BarGaugeSizing } from '@grafana/schema';
import { BarGauge, DataLinksContextMenu, VizLayout, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi } from '@grafana/ui/internal';
import { config } from 'app/core/config';
import { BarGaugeLegend } from './BarGaugeLegend';
import { defaultOptions, Options } from './panelcfg.gen';

View File

@@ -5,7 +5,7 @@ import { useMemo, useState } from 'react';
import uPlot from 'uplot';
import { Field, getDisplayProcessor, PanelProps, useDataLinksContext } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema';
import {
EventBusPlugin,
@@ -18,7 +18,6 @@ import {
} from '@grafana/ui';
import { AxisProps, ScaleProps, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
import { config } from 'app/core/config';
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';

Some files were not shown because too many files have changed in this diff Show More