Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44029c2631 | |||
| 0573be1920 |
@@ -106,10 +106,10 @@ describe('VizTooltipFooter', () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const onForButton = screen.getByRole('button', { name: /Apply as filter/i });
|
||||
const onForButton = screen.getByTestId(selectors.components.VizTooltipFooter.buttons.apply);
|
||||
expect(onForButton).toBeInTheDocument();
|
||||
|
||||
const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i });
|
||||
const onOutButton = screen.getByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse);
|
||||
expect(onOutButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(onForButton);
|
||||
|
||||
@@ -127,25 +127,19 @@ export const VizTooltipFooter = ({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={filterByGroupedLabels.onFilterForGroupedLabels}
|
||||
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter"
|
||||
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
|
||||
>
|
||||
Apply as filter
|
||||
</Trans>
|
||||
<Trans i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter">Filter on this value</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
icon="filter"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={filterByGroupedLabels.onFilterOutGroupedLabels}
|
||||
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter"
|
||||
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
|
||||
>
|
||||
Apply as inverse filter
|
||||
<Trans i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter">
|
||||
Filter out this value
|
||||
</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
)
|
||||
@@ -122,7 +121,7 @@ func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := c.handleMethodRequest(ctx, r, name, opts, isDir, dualReadWriter, readWriter)
|
||||
obj, err := c.handleMethodRequest(ctx, r, opts, isDir, dualReadWriter)
|
||||
if err != nil {
|
||||
logger.Debug("got an error after processing request", "error", err)
|
||||
respondWithError(responder, err)
|
||||
@@ -176,16 +175,8 @@ func (c *filesConnector) parseRequestOptions(r *http.Request, name string, repo
|
||||
}
|
||||
opts.Path = path
|
||||
|
||||
// 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
|
||||
}
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
return opts, err
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
@@ -208,10 +199,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, repoName string, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter, readWriter repository.ReaderWriter) (*provisioning.ResourceWrapper, error) {
|
||||
func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
return c.handleGet(ctx, repoName, opts, dualReadWriter, readWriter)
|
||||
return c.handleGet(ctx, opts, dualReadWriter)
|
||||
case http.MethodPost:
|
||||
return c.handlePost(ctx, r, opts, isDir, dualReadWriter)
|
||||
case http.MethodPut:
|
||||
@@ -223,12 +214,7 @@ func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -236,36 +222,6 @@ func (c *filesConnector) handleGet(ctx context.Context, repoName string, opts re
|
||||
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 != "" {
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
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,7 +3,6 @@ package resources
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
)
|
||||
@@ -16,65 +15,9 @@ var (
|
||||
|
||||
const maxPathDepth = 8
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
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
|
||||
@@ -88,5 +31,12 @@ func validatePathBasics(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,11 +38,6 @@ 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",
|
||||
@@ -75,127 +70,3 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,156 +594,6 @@ 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,17 +389,6 @@ 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()
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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;
|
||||
@@ -9,7 +9,6 @@ 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}`;
|
||||
@@ -20,35 +19,21 @@ 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,
|
||||
children: [
|
||||
{
|
||||
active: false,
|
||||
icon: 'apps',
|
||||
id: getDashboardsTabID(folder.uid),
|
||||
text: t('browse-dashboards.manage-folder-nav.dashboards', 'Dashboards'),
|
||||
url: folder.url,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (parents && parents.length > 0) {
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
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 };
|
||||
@@ -484,15 +484,6 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "TestStuffPage"*/ 'app/features/sandbox/TestStuffPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/dashboards/f/:uid/:slug/readme',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "FolderReadmePage"*/ 'app/features/browse-dashboards/BrowseFolderReadmePage'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/dashboards/f/:uid/:slug/library-panels',
|
||||
component: SafeDynamicImport(
|
||||
|
||||
@@ -9346,8 +9346,8 @@
|
||||
"actions-confirmation-label": "Confirmation message",
|
||||
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
|
||||
"footer-add-annotation": "Add annotation",
|
||||
"footer-apply-series-as-filter": "Apply as filter",
|
||||
"footer-apply-series-as-inverse-filter": "Apply as inverse filter",
|
||||
"footer-apply-series-as-filter": "Filter on this value",
|
||||
"footer-apply-series-as-inverse-filter": "Filter out this value",
|
||||
"footer-click-to-action": "Click to {{actionTitle}}",
|
||||
"footer-click-to-navigate": "Click to open {{linkTitle}}",
|
||||
"footer-filter-for-value": "Filter for '{{value}}'",
|
||||
|
||||
Reference in New Issue
Block a user