Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f49b4df5ff | |||
| 907bea63f2 | |||
| dca2fd6b8d | |||
| 91ab753368 | |||
| 250ca7985f | |||
| b57ed32484 | |||
| d0217588a3 | |||
| ce9ab6a89a | |||
| 8c8efd2494 | |||
| 69ccfd6bfc |
+7
-1
@@ -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
|
||||
|
||||
+8
-3
@@ -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/
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
@@ -121,7 +122,7 @@ func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := c.handleMethodRequest(ctx, r, opts, isDir, dualReadWriter)
|
||||
obj, err := c.handleMethodRequest(ctx, r, name, opts, isDir, dualReadWriter, readWriter)
|
||||
if err != nil {
|
||||
logger.Debug("got an error after processing request", "error", err)
|
||||
respondWithError(responder, err)
|
||||
@@ -175,8 +176,16 @@ func (c *filesConnector) parseRequestOptions(r *http.Request, name string, repo
|
||||
}
|
||||
opts.Path = path
|
||||
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
return opts, err
|
||||
// For GET requests, allow read-only files like .md in addition to resource files
|
||||
// For write operations, only allow resource files (yml, yaml, json)
|
||||
if r.Method == http.MethodGet {
|
||||
if err := resources.IsReadablePath(opts.Path); err != nil {
|
||||
return opts, err
|
||||
}
|
||||
} else {
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
return opts, err
|
||||
}
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
@@ -199,10 +208,10 @@ func (c *filesConnector) handleDirectoryListing(ctx context.Context, name string
|
||||
}
|
||||
|
||||
// handleMethodRequest routes the request to the appropriate handler based on HTTP method.
|
||||
func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, repoName string, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
return c.handleGet(ctx, opts, dualReadWriter)
|
||||
return c.handleGet(ctx, repoName, opts, dualReadWriter, readWriter)
|
||||
case http.MethodPost:
|
||||
return c.handlePost(ctx, r, opts, isDir, dualReadWriter)
|
||||
case http.MethodPut:
|
||||
@@ -214,7 +223,12 @@ func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
func (c *filesConnector) handleGet(ctx context.Context, repoName string, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// For raw files (like .md), read directly without parsing as k8s resource
|
||||
if resources.IsRawFile(opts.Path) {
|
||||
return c.handleGetRawFile(ctx, repoName, opts, readWriter)
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -222,6 +236,36 @@ func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWrite
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
// handleGetRawFile reads a raw file (like README.md) and returns it without parsing.
|
||||
func (c *filesConnector) handleGetRawFile(ctx context.Context, repoName string, opts resources.DualWriteOptions, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// Authorize the read operation
|
||||
if err := c.authorizeListFiles(ctx, repoName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := readWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
if err == repository.ErrFileNotFound {
|
||||
return nil, apierrors.NewNotFound(provisioning.RepositoryResourceInfo.GroupResource(), opts.Path)
|
||||
}
|
||||
return nil, fmt.Errorf("read raw file: %w", err)
|
||||
}
|
||||
|
||||
// Return the raw content in the ResourceWrapper
|
||||
return &provisioning.ResourceWrapper{
|
||||
Path: info.Path,
|
||||
Ref: info.Ref,
|
||||
Hash: info.Hash,
|
||||
Resource: provisioning.ResourceObjects{
|
||||
File: v0alpha1.Unstructured{
|
||||
Object: map[string]any{
|
||||
"content": string(info.Data),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// Check if this is a move operation (originalPath query parameter is present)
|
||||
if opts.OriginalPath != "" {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
func TestParseRequestOptionsPathValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "GET json file allowed",
|
||||
method: http.MethodGet,
|
||||
path: "dashboard.json",
|
||||
},
|
||||
{
|
||||
name: "GET yaml file allowed",
|
||||
method: http.MethodGet,
|
||||
path: "dashboard.yaml",
|
||||
},
|
||||
{
|
||||
name: "GET yml file allowed",
|
||||
method: http.MethodGet,
|
||||
path: "dashboard.yml",
|
||||
},
|
||||
{
|
||||
name: "GET markdown file allowed",
|
||||
method: http.MethodGet,
|
||||
path: "README.md",
|
||||
},
|
||||
{
|
||||
name: "GET nested markdown file allowed",
|
||||
method: http.MethodGet,
|
||||
path: "folder/subfolder/README.md",
|
||||
},
|
||||
{
|
||||
name: "GET txt file not allowed",
|
||||
method: http.MethodGet,
|
||||
path: "file.txt",
|
||||
wantErr: true,
|
||||
errContains: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "POST json file allowed",
|
||||
method: http.MethodPost,
|
||||
path: "dashboard.json",
|
||||
},
|
||||
{
|
||||
name: "POST markdown file not allowed",
|
||||
method: http.MethodPost,
|
||||
path: "README.md",
|
||||
wantErr: true,
|
||||
errContains: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "PUT markdown file not allowed",
|
||||
method: http.MethodPut,
|
||||
path: "README.md",
|
||||
wantErr: true,
|
||||
errContains: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "DELETE markdown file not allowed",
|
||||
method: http.MethodDelete,
|
||||
path: "README.md",
|
||||
wantErr: true,
|
||||
errContains: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "GET directory allowed",
|
||||
method: http.MethodGet,
|
||||
path: "dashboards/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockRepo := repository.NewMockRepository(t)
|
||||
mockRepo.On("Config").Return(&provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "test-repo",
|
||||
},
|
||||
}).Maybe()
|
||||
|
||||
connector := &filesConnector{}
|
||||
r := httptest.NewRequest(tt.method, "/test-repo/files/"+tt.path, nil)
|
||||
|
||||
opts, err := connector.parseRequestOptions(r, "test-repo", mockRepo)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.errContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.path, opts.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetRawFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
fileContent string
|
||||
readError error
|
||||
wantErr bool
|
||||
errContains string
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "successful readme read",
|
||||
path: "README.md",
|
||||
fileContent: "# Hello World\n\nThis is a test.",
|
||||
expectedResult: "# Hello World\n\nThis is a test.",
|
||||
},
|
||||
{
|
||||
name: "nested readme read",
|
||||
path: "folder/README.md",
|
||||
fileContent: "# Folder Readme",
|
||||
expectedResult: "# Folder Readme",
|
||||
},
|
||||
{
|
||||
name: "file not found",
|
||||
path: "README.md",
|
||||
readError: repository.ErrFileNotFound,
|
||||
wantErr: true,
|
||||
errContains: "not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockReadWriter := repository.NewMockReaderWriter(t)
|
||||
mockAccess := auth.NewMockAccessChecker(t)
|
||||
|
||||
// Setup auth mock - always allow
|
||||
mockAccess.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||
|
||||
// Setup Read mock
|
||||
if tt.readError != nil {
|
||||
mockReadWriter.EXPECT().Read(mock.Anything, tt.path, "").Return(nil, tt.readError)
|
||||
} else {
|
||||
mockReadWriter.EXPECT().Read(mock.Anything, tt.path, "").Return(&repository.FileInfo{
|
||||
Path: tt.path,
|
||||
Data: []byte(tt.fileContent),
|
||||
Ref: "main",
|
||||
Hash: "abc123",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
connector := &filesConnector{
|
||||
access: mockAccess,
|
||||
}
|
||||
|
||||
opts := resources.DualWriteOptions{
|
||||
Path: tt.path,
|
||||
Ref: "",
|
||||
}
|
||||
|
||||
result, err := connector.handleGetRawFile(context.Background(), "test-repo", opts, mockReadWriter)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.errContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, tt.path, result.Path)
|
||||
|
||||
// Check that content is in the response
|
||||
content, ok := result.Resource.File.Object["content"]
|
||||
require.True(t, ok, "content field should exist")
|
||||
require.Equal(t, tt.expectedResult, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRawFileIntegration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "README.md is raw",
|
||||
path: "README.md",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nested README.md is raw",
|
||||
path: "folder/subfolder/README.md",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "dashboard.json is not raw",
|
||||
path: "dashboard.json",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "dashboard.yaml is not raw",
|
||||
path: "dashboard.yaml",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "directory is not raw",
|
||||
path: "folder/",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := resources.IsRawFile(tt.path)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package resources
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
)
|
||||
@@ -15,9 +16,65 @@ var (
|
||||
|
||||
const maxPathDepth = 8
|
||||
|
||||
// IsPathSupported checks if the file path is supported by the provisioning API.
|
||||
// it also validates if the path is safe and if the file extension is supported.
|
||||
// resourceExtensions are file extensions that contain k8s resources and can be parsed
|
||||
var resourceExtensions = map[string]bool{
|
||||
".yml": true,
|
||||
".yaml": true,
|
||||
".json": true,
|
||||
}
|
||||
|
||||
// readOnlyExtensions are file extensions that can be read as raw content (read-only)
|
||||
var readOnlyExtensions = map[string]bool{
|
||||
".md": true,
|
||||
}
|
||||
|
||||
// IsPathSupported checks if the file path is supported by the provisioning API for write operations.
|
||||
// It validates if the path is safe and if the file extension is a resource type (yml, yaml, json).
|
||||
func IsPathSupported(filePath string) error {
|
||||
if err := validatePathBasics(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only check file extension if it's not a folder path
|
||||
if !safepath.IsDir(filePath) {
|
||||
ext := path.Ext(filePath)
|
||||
if !resourceExtensions[ext] {
|
||||
return ErrUnsupportedFileExtension
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsReadablePath checks if the file path is supported for read operations.
|
||||
// This includes resource files (yml, yaml, json) and read-only files (md).
|
||||
func IsReadablePath(filePath string) error {
|
||||
if err := validatePathBasics(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only check file extension if it's not a folder path
|
||||
if !safepath.IsDir(filePath) {
|
||||
ext := path.Ext(filePath)
|
||||
if !resourceExtensions[ext] && !readOnlyExtensions[ext] {
|
||||
return ErrUnsupportedFileExtension
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRawFile checks if the file path is a read-only raw file (not a k8s resource).
|
||||
func IsRawFile(filePath string) bool {
|
||||
if safepath.IsDir(filePath) {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(filePath))
|
||||
return readOnlyExtensions[ext]
|
||||
}
|
||||
|
||||
// validatePathBasics performs common path validation checks.
|
||||
func validatePathBasics(filePath string) error {
|
||||
// Validate the path for any traversal attempts first
|
||||
if err := safepath.IsSafe(filePath); err != nil {
|
||||
return err
|
||||
@@ -31,12 +88,5 @@ func IsPathSupported(filePath string) error {
|
||||
return ErrNotRelative
|
||||
}
|
||||
|
||||
// Only check file extension if it's not a folder path
|
||||
if !safepath.IsDir(filePath) {
|
||||
if ext := path.Ext(filePath); ext != ".yml" && ext != ".yaml" && ext != ".json" {
|
||||
return ErrUnsupportedFileExtension
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ func TestIsPathSupported(t *testing.T) {
|
||||
path: "dashboards/my-dashboard.txt",
|
||||
expectedErr: ErrUnsupportedFileExtension,
|
||||
},
|
||||
{
|
||||
name: "markdown file not supported for write",
|
||||
path: "dashboards/README.md",
|
||||
expectedErr: ErrUnsupportedFileExtension,
|
||||
},
|
||||
{
|
||||
name: "path traversal attempt",
|
||||
path: "../dashboards/my-dashboard.yaml",
|
||||
@@ -70,3 +75,127 @@ func TestIsPathSupported(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReadablePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "valid yaml file",
|
||||
path: "dashboards/my-dashboard.yaml",
|
||||
},
|
||||
{
|
||||
name: "valid yml file",
|
||||
path: "dashboards/my-dashboard.yml",
|
||||
},
|
||||
{
|
||||
name: "valid json file",
|
||||
path: "dashboards/my-dashboard.json",
|
||||
},
|
||||
{
|
||||
name: "valid markdown file",
|
||||
path: "dashboards/README.md",
|
||||
},
|
||||
{
|
||||
name: "markdown file in nested path",
|
||||
path: "dashboards/folder1/folder2/README.md",
|
||||
},
|
||||
{
|
||||
name: "valid directory path",
|
||||
path: "dashboards/folder1/",
|
||||
},
|
||||
{
|
||||
name: "unsupported file extension",
|
||||
path: "dashboards/my-dashboard.txt",
|
||||
expectedErr: ErrUnsupportedFileExtension,
|
||||
},
|
||||
{
|
||||
name: "path traversal attempt",
|
||||
path: "../dashboards/README.md",
|
||||
expectedErr: safepath.ErrPathTraversalAttempt,
|
||||
},
|
||||
{
|
||||
name: "path too deep",
|
||||
path: "level1/level2/level3/level4/level5/level6/level7/level8/level9/README.md",
|
||||
expectedErr: ErrPathTooDeep,
|
||||
},
|
||||
{
|
||||
name: "absolute path",
|
||||
path: "/etc/dashboards/README.md",
|
||||
expectedErr: ErrNotRelative,
|
||||
},
|
||||
{
|
||||
name: "empty directory path",
|
||||
path: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := IsReadablePath(tt.path)
|
||||
if tt.expectedErr != nil {
|
||||
require.ErrorIs(t, err, tt.expectedErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRawFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "markdown file is raw",
|
||||
path: "README.md",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nested markdown file is raw",
|
||||
path: "dashboards/folder/README.md",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "uppercase MD extension",
|
||||
path: "README.MD",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "yaml file is not raw",
|
||||
path: "dashboard.yaml",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "json file is not raw",
|
||||
path: "dashboard.json",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "yml file is not raw",
|
||||
path: "dashboard.yml",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "directory is not raw",
|
||||
path: "dashboards/",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty path is not raw",
|
||||
path: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsRawFile(tt.path)
|
||||
require.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
Generated
-1
@@ -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
|
||||
|
||||
|
+2
-1
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,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)
|
||||
})
|
||||
}
|
||||
@@ -686,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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -594,6 +594,156 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_ReadmeFiles(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "readme-test-repo"
|
||||
const readmeContent = "# Test Repository\n\nThis is a test README for the provisioning API."
|
||||
|
||||
// Create a repo with a README.md file
|
||||
helper.CreateRepo(t, TestRepo{
|
||||
Name: repo,
|
||||
Path: helper.ProvisioningPath,
|
||||
Target: "instance",
|
||||
Copies: map[string]string{
|
||||
"testdata/all-panels.json": "dashboard1.json",
|
||||
},
|
||||
ExpectedDashboards: 1,
|
||||
ExpectedFolders: 0,
|
||||
})
|
||||
|
||||
// Write README.md directly to the provisioning path
|
||||
helper.WriteToProvisioningPath(t, "README.md", []byte(readmeContent))
|
||||
|
||||
t.Run("GET README.md file should succeed", func(t *testing.T) {
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "should return 200 OK for README.md")
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the response
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(body, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the response contains the file content
|
||||
resource, ok := result["resource"].(map[string]interface{})
|
||||
require.True(t, ok, "response should have resource field")
|
||||
|
||||
file, ok := resource["file"].(map[string]interface{})
|
||||
require.True(t, ok, "resource should have file field")
|
||||
|
||||
content, ok := file["content"].(string)
|
||||
require.True(t, ok, "file should have content field")
|
||||
require.Equal(t, readmeContent, content, "content should match the README")
|
||||
})
|
||||
|
||||
t.Run("GET nested README.md should succeed", func(t *testing.T) {
|
||||
// Write a nested README.md
|
||||
nestedReadmeContent := "# Nested Folder README\n\nThis is inside a folder."
|
||||
helper.WriteToProvisioningPath(t, "folder/README.md", []byte(nestedReadmeContent))
|
||||
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/folder/README.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "should return 200 OK for nested README.md")
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the response
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(body, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check the content
|
||||
resource := result["resource"].(map[string]interface{})
|
||||
file := resource["file"].(map[string]interface{})
|
||||
content := file["content"].(string)
|
||||
require.Equal(t, nestedReadmeContent, content, "nested README content should match")
|
||||
})
|
||||
|
||||
t.Run("GET non-existent README.md should return 404", func(t *testing.T) {
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/nonexistent/README.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode, "should return 404 for non-existent README.md")
|
||||
})
|
||||
|
||||
t.Run("POST README.md should be rejected", func(t *testing.T) {
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/new-readme.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString("# New README"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return 400 for POST .md files")
|
||||
})
|
||||
|
||||
t.Run("PUT README.md should be rejected", func(t *testing.T) {
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewBufferString("# Updated README"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return 400 for PUT .md files")
|
||||
})
|
||||
|
||||
t.Run("DELETE README.md should be rejected", func(t *testing.T) {
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return 400 for DELETE .md files")
|
||||
})
|
||||
|
||||
t.Run("viewer can GET README.md", func(t *testing.T) {
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://viewer:viewer@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/README.md", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "viewer should be able to GET README.md")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_FilesAuthorization(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
|
||||
@@ -389,6 +389,17 @@ func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to s
|
||||
require.NoError(t, err, "failed to write file to provisioning path")
|
||||
}
|
||||
|
||||
// WriteToProvisioningPath writes content directly to a file in the provisioning path.
|
||||
func (h *provisioningTestHelper) WriteToProvisioningPath(t *testing.T, to string, content []byte) {
|
||||
fullPath := path.Join(h.ProvisioningPath, to)
|
||||
t.Logf("Writing to provisioning path '%s'", fullPath)
|
||||
err := os.MkdirAll(path.Dir(fullPath), 0o750)
|
||||
require.NoError(t, err, "failed to create directories for provisioning path")
|
||||
|
||||
err = os.WriteFile(fullPath, content, 0o600)
|
||||
require.NoError(t, err, "failed to write file to provisioning path")
|
||||
}
|
||||
|
||||
// DebugState logs the current state of filesystem, repository, and Grafana resources for debugging
|
||||
func (h *provisioningTestHelper) DebugState(t *testing.T, repo string, label string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
|
||||
import { ManagerKind } from '../apiserver/types';
|
||||
import { FolderActionsButton } from '../browse-dashboards/components/FolderActionsButton';
|
||||
import { buildNavModel, getReadmeTabID } from '../folders/state/navModel';
|
||||
import { FolderReadmeContent } from '../provisioning/components/Folders/FolderReadme';
|
||||
import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView';
|
||||
|
||||
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||
|
||||
export function BrowseFolderReadmePage() {
|
||||
const { uid: folderUID = '' } = useParams();
|
||||
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
|
||||
const [saveFolder] = useUpdateFolder();
|
||||
const { repoType, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUID });
|
||||
|
||||
const navModel = useMemo(() => {
|
||||
if (!folderDTO) {
|
||||
return undefined;
|
||||
}
|
||||
const model = buildNavModel(folderDTO);
|
||||
|
||||
// Set the "README" tab to active
|
||||
const readmeTabID = getReadmeTabID(folderDTO.uid);
|
||||
const readmeTab = model.children?.find((child) => child.id === readmeTabID);
|
||||
if (readmeTab) {
|
||||
readmeTab.active = true;
|
||||
}
|
||||
return model;
|
||||
}, [folderDTO]);
|
||||
|
||||
const isProvisionedFolder = folderDTO?.managedBy === ManagerKind.Repo;
|
||||
|
||||
const onEditTitle =
|
||||
folderUID && !isProvisionedFolder
|
||||
? async (newValue: string) => {
|
||||
if (folderDTO) {
|
||||
const result = await saveFolder({
|
||||
...folderDTO,
|
||||
title: newValue,
|
||||
});
|
||||
if ('error' in result) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Page
|
||||
navId="dashboards/browse"
|
||||
pageNav={navModel}
|
||||
onEditTitle={onEditTitle}
|
||||
actions={<>{folderDTO && <FolderActionsButton folder={folderDTO} repoType={repoType} isReadOnlyRepo={isReadOnlyRepo} />}</>}
|
||||
>
|
||||
<Page.Contents>
|
||||
<FolderReadmeContent folderUID={folderUID} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrowseFolderReadmePage;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,7 +12,7 @@ jest.mock('@grafana/faro-web-sdk', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('app/core/config', () => ({
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
config: {
|
||||
grafanaJavascriptAgent: {
|
||||
enabled: true,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-17
@@ -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();
|
||||
|
||||
|
||||
+7
-2
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FolderDTO, FolderParent } from 'app/types/folders';
|
||||
|
||||
export const FOLDER_ID = 'manage-folder';
|
||||
|
||||
export const getReadmeTabID = (folderUID: string) => `folder-readme-${folderUID}`;
|
||||
export const getDashboardsTabID = (folderUID: string) => `folder-dashboards-${folderUID}`;
|
||||
export const getLibraryPanelsTabID = (folderUID: string) => `folder-library-panels-${folderUID}`;
|
||||
export const getAlertingTabID = (folderUID: string) => `folder-alerting-${folderUID}`;
|
||||
@@ -19,21 +20,35 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
|
||||
const parents = parentsArg ?? ('parents' in folder ? folder.parents : undefined);
|
||||
const isProvisioned = 'managedBy' in folder ? folder.managedBy === ManagerKind.Repo : false;
|
||||
|
||||
const children: NavModelItem[] = [];
|
||||
|
||||
// Add README tab first for provisioned folders
|
||||
if (isProvisioned && config.featureToggles.provisioning) {
|
||||
children.push({
|
||||
active: false,
|
||||
icon: 'document-info',
|
||||
id: getReadmeTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.readme', 'README'),
|
||||
url: `${folder.url}/readme`,
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboards tab
|
||||
children.push({
|
||||
active: false,
|
||||
icon: 'apps',
|
||||
id: getDashboardsTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'),
|
||||
url: folder.url,
|
||||
});
|
||||
|
||||
const model: NavModelItem = {
|
||||
icon: 'folder',
|
||||
id: FOLDER_ID,
|
||||
subTitle: getNavSubTitle('manage-folder'),
|
||||
url: folder.url,
|
||||
text: folder.title,
|
||||
children: [
|
||||
{
|
||||
active: false,
|
||||
icon: 'apps',
|
||||
id: getDashboardsTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'),
|
||||
url: folder.url,
|
||||
},
|
||||
],
|
||||
children,
|
||||
};
|
||||
|
||||
if (parents && parents.length > 0) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { useGetRepositoryFilesWithPathQuery, RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView';
|
||||
|
||||
import { FolderReadmeContent } from './FolderReadme';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
|
||||
useGetRepositoryFilesWithPathQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../hooks/useGetResourceRepositoryView');
|
||||
|
||||
const mockUseGetRepositoryFilesWithPathQuery = useGetRepositoryFilesWithPathQuery as jest.MockedFunction<
|
||||
typeof useGetRepositoryFilesWithPathQuery
|
||||
>;
|
||||
|
||||
const mockUseGetResourceRepositoryView = useGetResourceRepositoryView as jest.MockedFunction<
|
||||
typeof useGetResourceRepositoryView
|
||||
>;
|
||||
|
||||
const mockRepository: RepositoryView = {
|
||||
name: 'test-repo',
|
||||
target: 'folder' as const,
|
||||
title: 'Test Repository',
|
||||
type: 'git' as const,
|
||||
workflows: [],
|
||||
};
|
||||
|
||||
const mockFolder = {
|
||||
metadata: {
|
||||
name: 'test-folder',
|
||||
annotations: {
|
||||
'grafana.app/sourcePath': 'dashboards/team-a',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
title: 'Test Folder',
|
||||
},
|
||||
status: {},
|
||||
};
|
||||
|
||||
describe('FolderReadmeContent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when folder is not provisioned', () => {
|
||||
it('should show not provisioned message when repository is undefined', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: undefined,
|
||||
folder: undefined,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/not managed by a Git repository/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading', () => {
|
||||
it('should show loading spinner while loading repository info', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: true,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading spinner while fetching README', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when README fetch fails', () => {
|
||||
it('should show not found message on error', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: { status: 404, data: 'Not found' },
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show not found message when file data is empty', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/No README.md file found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when README is successfully fetched', () => {
|
||||
it('should render markdown content', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: {
|
||||
resource: {
|
||||
file: {
|
||||
content: '# Hello World\n\nThis is a test README.',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
|
||||
// The markdown should be rendered as HTML
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a test README.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle string content directly', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: {
|
||||
resource: {
|
||||
file: '# Direct String Content',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText('Direct String Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show parse error when file content cannot be extracted', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: {
|
||||
resource: {
|
||||
file: { someUnexpectedFormat: true },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
expect(screen.getByText(/Unable to display README content/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('README path construction', () => {
|
||||
it('should use source path from folder annotations', () => {
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: mockFolder,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
|
||||
// Verify the query was called with the correct path
|
||||
expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({
|
||||
name: 'test-repo',
|
||||
path: 'dashboards/team-a/README.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use root README.md when no source path is set', () => {
|
||||
const folderWithoutPath = {
|
||||
metadata: {
|
||||
name: 'test-folder',
|
||||
annotations: {},
|
||||
},
|
||||
spec: { title: 'Test Folder' },
|
||||
status: {},
|
||||
};
|
||||
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
repository: mockRepository,
|
||||
folder: folderWithoutPath,
|
||||
isLoading: false,
|
||||
isInstanceManaged: false,
|
||||
isReadOnlyRepo: false,
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesWithPathQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<FolderReadmeContent folderUID="test-folder" />);
|
||||
|
||||
expect(mockUseGetRepositoryFilesWithPathQuery).toHaveBeenCalledWith({
|
||||
name: 'test-repo',
|
||||
path: 'README.md',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { GrafanaTheme2, renderMarkdown } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Box, Spinner, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { useGetRepositoryFilesWithPathQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||
|
||||
import { useGetResourceRepositoryView } from '../../hooks/useGetResourceRepositoryView';
|
||||
|
||||
interface FolderReadmeContentProps {
|
||||
folderUID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FolderReadmeContent fetches and renders a README.md file from a Git Sync provisioned folder.
|
||||
* This is the main content component used in the README tab.
|
||||
*/
|
||||
export function FolderReadmeContent({ folderUID }: FolderReadmeContentProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Get repository info for the folder
|
||||
const { repository, folder, isLoading: isRepoLoading } = useGetResourceRepositoryView({
|
||||
folderName: folderUID,
|
||||
});
|
||||
|
||||
// Construct the README path based on the folder's source path annotation
|
||||
const sourcePath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || '';
|
||||
const readmePath = sourcePath ? `${sourcePath}/README.md` : 'README.md';
|
||||
|
||||
// Determine if we should fetch the README
|
||||
const shouldFetch = !!repository && !!folderUID && !isRepoLoading;
|
||||
|
||||
// Fetch the README.md file from the repository
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: isFileLoading,
|
||||
isError,
|
||||
} = useGetRepositoryFilesWithPathQuery(
|
||||
shouldFetch
|
||||
? {
|
||||
name: repository.name,
|
||||
path: readmePath,
|
||||
}
|
||||
: skipToken
|
||||
);
|
||||
|
||||
// Show loading spinner while fetching repository info or README
|
||||
if (isRepoLoading || isFileLoading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" paddingY={4}>
|
||||
<Spinner size="lg" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state if folder is not managed by a repository
|
||||
if (!repository) {
|
||||
return (
|
||||
<Box paddingY={4}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="browse-dashboards.readme.not-provisioned">
|
||||
This folder is not managed by a Git repository.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state if there was an error or no README exists
|
||||
if (isError || !fileData) {
|
||||
return (
|
||||
<Box paddingY={4}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="browse-dashboards.readme.not-found">
|
||||
No README.md file found in this folder.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
<Trans i18nKey="browse-dashboards.readme.add-hint">
|
||||
Add a README.md file to your repository to display documentation here.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the raw content from the file data
|
||||
const fileContent = fileData.resource?.file;
|
||||
if (!fileContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to get the content as a string
|
||||
let markdownContent: string | undefined;
|
||||
|
||||
if (typeof fileContent === 'string') {
|
||||
markdownContent = fileContent;
|
||||
} else if (typeof fileContent === 'object') {
|
||||
markdownContent =
|
||||
(fileContent as Record<string, unknown>).content as string | undefined ||
|
||||
(fileContent as Record<string, unknown>).data as string | undefined ||
|
||||
(fileContent as Record<string, unknown>).spec as string | undefined;
|
||||
|
||||
if (!markdownContent && (fileContent as Record<string, unknown>).raw) {
|
||||
markdownContent = (fileContent as Record<string, unknown>).raw as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdownContent || typeof markdownContent !== 'string') {
|
||||
return (
|
||||
<Box paddingY={4}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="browse-dashboards.readme.parse-error">
|
||||
Unable to display README content.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the markdown content
|
||||
const renderedHtml = renderMarkdown(markdownContent);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="markdown-html" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
});
|
||||
|
||||
// Keep the old export for backwards compatibility during transition
|
||||
export { FolderReadmeContent as FolderReadme };
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user