Compare commits

..

2 Commits

Author SHA1 Message Date
Victor Marin 44029c2631 refactor 2026-01-13 15:21:24 +02:00
Victor Marin 0573be1920 rename tooltips 2026-01-13 10:39:36 +02:00
14 changed files with 33 additions and 1192 deletions
@@ -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>
+6 -50
View File
@@ -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)
})
}
}
-150
View File
@@ -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 -24
View File
@@ -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 };
-9
View File
@@ -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(
+2 -2
View File
@@ -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}}'",