Merge branch 'main' into wb/pluginmeta-local

This commit is contained in:
Will Browne
2025-12-11 16:59:33 +00:00
49 changed files with 1621 additions and 571 deletions
+6
View File
@@ -83,6 +83,12 @@ tree:
nodeType: leaf
linkId: test-case-2
linkType: scope
test-case-redirect:
title: Test case with redirect
nodeType: leaf
linkId: shoe-org
linkType: scope
redirectPath: /d/dcb9f5e9-8066-4397-889e-864b99555dbb #Reliability dashboard
clusters:
title: Clusters
nodeType: container
+7
View File
@@ -67,10 +67,12 @@ type ScopeFilterConfig struct {
type TreeNode struct {
Title string `yaml:"title"`
SubTitle string `yaml:"subTitle,omitempty"`
Description string `yaml:"description,omitempty"`
NodeType string `yaml:"nodeType"`
LinkID string `yaml:"linkId,omitempty"`
LinkType string `yaml:"linkType,omitempty"`
DisableMultiSelect bool `yaml:"disableMultiSelect,omitempty"`
RedirectPath string `yaml:"redirectPath,omitempty"`
Children map[string]TreeNode `yaml:"children,omitempty"`
}
@@ -259,6 +261,7 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string)
spec := v0alpha1.ScopeNodeSpec{
Title: node.Title,
SubTitle: node.SubTitle,
Description: node.Description,
NodeType: nodeType,
DisableMultiSelect: node.DisableMultiSelect,
}
@@ -272,6 +275,10 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string)
spec.LinkType = linkType
}
if node.RedirectPath != "" {
spec.RedirectPath = node.RedirectPath
}
resource := v0alpha1.ScopeNode{
TypeMeta: metav1.TypeMeta{
APIVersion: apiVersion,
@@ -54,7 +54,7 @@ For production systems, use the `folderFromFilesStructure` capability instead of
## Before you begin
{{< admonition type="note" >}}
Enable the `provisioning` and `kubernetesDashboards` feature toggles in Grafana to use this feature.
Enable the `provisioning` feature toggle in Grafana to use this feature.
{{< /admonition >}}
To set up file provisioning, you need:
@@ -67,7 +67,7 @@ To set up file provisioning, you need:
## Enable required feature toggles and configure permitted paths
To activate local file provisioning in Grafana, you need to enable the `provisioning` and `kubernetesDashboards` feature toggles.
To activate local file provisioning in Grafana, you need to enable the `provisioning` feature toggle.
For additional information about feature toggles, refer to [Configure feature toggles](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles).
The local setting must be a relative path and its relative path must be configured in the `permitted_provisioned_paths` configuration option.
@@ -82,12 +82,11 @@ Any subdirectories are automatically included.
The values that you enter for the `permitted_provisioning_paths` become the base paths for those entered when you enter a local path in the **Connect to local storage** wizard.
1. Open your Grafana configuration file, either `grafana.ini` or `custom.ini`. For file location based on operating system, refer to [Configuration file location](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#experimental-feature-toggles).
1. Locate or add a `[feature_toggles]` section. Add these values:
1. Locate or add a `[feature_toggles]` section. Add this value:
```ini
[feature_toggles]
provisioning = true
kubernetesDashboards = true ; use k8s from browser
```
1. Locate or add a `[paths]` section. To add more than one location, use the pipe character (`|`) to separate the paths. The list should not include empty paths or trailing pipes. Add these values:
@@ -74,17 +74,16 @@ Alternatively, you can configure a local file system instead of using GitHub. Re
## Enable required feature toggles
To activate Git Sync in Grafana, you need to enable the `provisioning` and `kubernetesDashboards` feature toggles. For more information about feature toggles, refer to [Configure feature toggles](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#experimental-feature-toggles).
To activate Git Sync in Grafana, you need to enable the `provisioning` feature toggle. For more information about feature toggles, refer to [Configure feature toggles](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#experimental-feature-toggles).
To enable the required feature toggles:
To enable the required feature toggle:
1. Open your Grafana configuration file, either `grafana.ini` or `custom.ini`. For file location based on operating system, refer to [Configuration file location](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/feature-toggles/#experimental-feature-toggles).
1. Locate or add a `[feature_toggles]` section. Add these values:
1. Locate or add a `[feature_toggles]` section. Add this value:
```ini
[feature_toggles]
provisioning = true
kubernetesDashboards = true ; use k8s from browser
```
1. Save the changes to the file and restart Grafana.
@@ -0,0 +1,80 @@
---
description: Learn how to troubleshoot common problems with the Grafana MySQL data source plugin
keywords:
- grafana
- mysql
- query
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Troubleshoot
title: Troubleshoot common problems with the Grafana MySQL data source plugin
weight: 40
refs:
variables:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/dashboards/variables/
variable-syntax-advanced-variable-format-options:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/#advanced-variable-format-options
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/dashboards/variables/variable-syntax/#advanced-variable-format-options
annotate-visualizations:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/
explore:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
query-transform-data:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/
panel-inspector:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/panel-inspector/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-inspector/
query-editor:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#query-editors
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/#query-editors
alert-rules:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/alert-rules/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/
template-annotations-and-labels:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/templates/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templates/
configure-standard-options:
- pattern: /docs/grafana/
- destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/configure-standard-options/
---
# Troubleshoot common problems with the Grafana MySQL data source plugin
This page lists common issues you might experience when setting up the Grafana MySQL data source plugin.
### My data source connection fails when using the Grafana MySQL data source plugin
- Check if the MySQL server is up and running.
- Make sure that your firewall is open for MySQL server (default port is `3306`).
- Ensure that you have the correct permissions to access the MySQL server and also have permission to access the database.
- If the error persists, create a new user for the Grafana MySQL data source plugin with correct permissions and try to connect with it.
### What should I do if I see "An unexpected error happened" or "Could not connect to MySQL" after trying all of the above?
- Check the Grafana logs for more details about the error.
- For Grafana Cloud customers, contact support.
@@ -8,6 +8,7 @@ test.use({
scopeFilters: true,
groupByVariable: true,
reloadDashboardsOnParamsChange: true,
useScopesNavigationEndpoint: true,
},
});
@@ -61,31 +62,6 @@ test.describe('Scope Redirect Functionality', () => {
});
});
test('should fall back to scope navigation when no redirectUrl', async ({ page, gotoDashboardPage }) => {
const scopes = testScopesWithRedirect();
await test.step('Navigate to dashboard and open scopes selector', async () => {
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
await openScopesSelector(page, scopes);
});
await test.step('Select scope without redirectUrl', async () => {
// Select the scope without redirectUrl directly
await selectScope(page, 'sn-redirect-fallback', scopes[1]);
});
await test.step('Apply scopes and verify fallback behavior', async () => {
await applyScopes(page, [scopes[1]]);
// Should stay on current dashboard since no redirectUrl is provided
// The scope navigation fallback should not redirect (as per existing behavior)
await expect(page).toHaveURL(/\/d\/cuj-dashboard-1/);
// Verify the scope was applied
await expect(page).toHaveURL(/scopes=scope-sn-redirect-fallback/);
});
});
test('should not redirect when reloading page on dashboard not in dashboard list', async ({
page,
gotoDashboardPage,
@@ -171,4 +147,47 @@ test.describe('Scope Redirect Functionality', () => {
await expect(page).not.toHaveURL(/scopes=/);
});
});
test('should not redirect to redirectPath when on active scope navigation', async ({ page, gotoDashboardPage }) => {
const scopes = testScopesWithRedirect();
await test.step('Set up scope navigation to dashboard-1', async () => {
// First, apply a scope that creates scope navigation to dashboard-1 (without redirectPath)
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
await openScopesSelector(page, scopes);
await selectScope(page, 'sn-redirect-setup', scopes[2]);
await applyScopes(page, [scopes[2]]);
// Verify we're on dashboard-1 with the scope applied
await expect(page).toHaveURL(/\/d\/cuj-dashboard-1/);
await expect(page).toHaveURL(/scopes=scope-sn-redirect-setup/);
});
await test.step('Navigate to dashboard-1 to be on active scope navigation', async () => {
// Navigate to dashboard-1 which is now a scope navigation target
await gotoDashboardPage({
uid: 'cuj-dashboard-1',
queryParams: new URLSearchParams({ scopes: 'scope-sn-redirect-setup' }),
});
// Verify we're on dashboard-1
await expect(page).toHaveURL(/\/d\/cuj-dashboard-1/);
});
await test.step('Apply scope with redirectPath and verify no redirect', async () => {
// Now apply a different scope that has redirectPath
// Since we're on an active scope navigation, it should NOT redirect
await openScopesSelector(page, scopes);
await selectScope(page, 'sn-redirect-with-navigation', scopes[3]);
await applyScopes(page, [scopes[3]]);
// Verify the new scope was applied
await expect(page).toHaveURL(/scopes=scope-sn-redirect-with-navigation/);
// Since we're already on the active scope navigation (dashboard-1),
// we should NOT redirect to redirectPath (dashboard-3)
await expect(page).toHaveURL(/\/d\/cuj-dashboard-1/);
await expect(page).not.toHaveURL(/\/d\/cuj-dashboard-3/);
});
});
});
+53 -3
View File
@@ -156,13 +156,18 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) {
return;
}
const url: string =
const dashboardBindingsUrl: string =
'**/apis/scope.grafana.app/v0alpha1/namespaces/*/find/scope_dashboard_bindings?' +
scopes.map((scope) => `scope=scope-${scope.name}`).join('&');
const scopeNavigationsUrl: string =
'**/apis/scope.grafana.app/v0alpha1/namespaces/*/find/scope_navigations?' +
scopes.map((scope) => `scope=scope-${scope.name}`).join('&');
const groups: string[] = ['Most relevant', 'Dashboards', 'Something else', ''];
await page.route(url, async (route) => {
// Mock scope_dashboard_bindings endpoint
await page.route(dashboardBindingsUrl, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
@@ -215,7 +220,52 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) {
});
});
const responsePromise = page.waitForResponse((response) => response.url().includes(`/find/scope_dashboard_bindings`));
// Mock scope_navigations endpoint
await page.route(scopeNavigationsUrl, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
apiVersion: 'scope.grafana.app/v0alpha1',
items: scopes.flatMap((scope) => {
const navigations: Array<{
kind: string;
apiVersion: string;
metadata: { name: string; resourceVersion: string; creationTimestamp: string };
spec: { url: string; scope: string };
status: { title: string };
}> = [];
// Create a scope navigation if dashboardUid is provided
if (scope.dashboardUid && scope.addLinks) {
navigations.push({
kind: 'ScopeNavigation',
apiVersion: 'scope.grafana.app/v0alpha1',
metadata: {
name: `scope-${scope.name}-nav`,
resourceVersion: '1',
creationTimestamp: 'stamp',
},
spec: {
url: `/d/${scope.dashboardUid}`,
scope: `scope-${scope.name}`,
},
status: {
title: scope.dashboardTitle ?? scope.title,
},
});
}
return navigations;
}),
}),
});
});
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/find/scope_dashboard_bindings`) || response.url().includes(`/find/scope_navigations`)
);
const scopeRequestPromises: Array<Promise<Response>> = [];
for (const scope of scopes) {
+18
View File
@@ -124,5 +124,23 @@ export const testScopesWithRedirect = (): TestScope[] => {
dashboardTitle: 'CUJ Dashboard 2',
addLinks: true,
},
{
name: 'sn-redirect-setup',
title: 'Setup Navigation',
// No redirectPath - used to set up scope navigation to dashboard-1
filters: [{ key: 'namespace', operator: 'equals', value: 'setup-nav' }],
dashboardUid: 'cuj-dashboard-1', // Creates scope navigation to this dashboard
dashboardTitle: 'CUJ Dashboard 1',
addLinks: true,
},
{
name: 'sn-redirect-with-navigation',
title: 'Redirect With Navigation',
redirectPath: '/d/cuj-dashboard-3', // Redirect target
filters: [{ key: 'namespace', operator: 'equals', value: 'redirect-with-nav' }],
dashboardUid: 'cuj-dashboard-1', // Creates scope navigation to this dashboard
dashboardTitle: 'CUJ Dashboard 1',
addLinks: true,
},
];
};
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* A library containing the different design components of the Grafana ecosystem.
* A library containing e2e selectors for the Grafana ecosystem.
*
* @packageDocumentation
*/
@@ -451,6 +451,19 @@ describe('TableNG', () => {
expect(screen.getByText('A1')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
});
it('shows full column name in title attribute for truncated headers', () => {
const { container } = render(
<TableNG enableVirtualization={false} data={createBasicDataFrame()} width={800} height={600} />
);
const headers = container.querySelectorAll('[role="columnheader"]');
const firstHeaderSpan = headers[0].querySelector('span');
const secondHeaderSpan = headers[1].querySelector('span');
expect(firstHeaderSpan).toHaveAttribute('title', 'Column A');
expect(secondHeaderSpan).toHaveAttribute('title', 'Column B');
});
});
describe('Footer options', () => {
@@ -55,7 +55,9 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
{showTypeIcons && (
<Icon className={styles.headerCellIcon} name={getFieldTypeIcon(field)} title={field?.type} size="sm" />
)}
<span className={styles.headerCellLabel}>{getDisplayName(field)}</span>
<span className={styles.headerCellLabel} title={displayName}>
{displayName}
</span>
{direction && (
<Icon
className={cx(styles.headerCellIcon, styles.headerSortIcon)}
+170 -82
View File
@@ -2,27 +2,26 @@ package setting
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gopkg.in/ini.v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/dynamic"
clientrest "k8s.io/client-go/rest"
"k8s.io/client-go/rest"
"k8s.io/client-go/transport"
authlib "github.com/grafana/authlib/authn"
logging "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/semconv"
)
@@ -38,18 +37,11 @@ const (
ApiGroup = "setting.grafana.app"
apiVersion = "v0alpha1"
resource = "settings"
kind = "Setting"
listKind = "SettingList"
)
var settingGroupVersion = schema.GroupVersionResource{
Group: ApiGroup,
Version: apiVersion,
Resource: resource,
}
var settingGroupListKind = map[schema.GroupVersionResource]string{
settingGroupVersion: listKind,
var settingGroupVersion = schema.GroupVersion{
Group: ApiGroup,
Version: apiVersion,
}
type remoteSettingServiceMetrics struct {
@@ -106,10 +98,10 @@ type Service interface {
}
type remoteSettingService struct {
dynamicClient dynamic.Interface
log logging.Logger
pageSize int64
metrics remoteSettingServiceMetrics
restClient *rest.RESTClient
log logging.Logger
pageSize int64
metrics remoteSettingServiceMetrics
}
var _ Service = (*remoteSettingService)(nil)
@@ -126,7 +118,7 @@ type Config struct {
// At least one of WrapTransport or TokenExchangeClient is required.
WrapTransport transport.WrapperFunc
// TLSClientConfig configures TLS for the client connection.
TLSClientConfig clientrest.TLSClientConfig
TLSClientConfig rest.TLSClientConfig
// QPS limits requests per second (defaults to DefaultQPS).
QPS float32
// Burst allows request bursts above QPS (defaults to DefaultBurst).
@@ -145,29 +137,39 @@ type Setting struct {
Value string `json:"value"`
}
// settingResource represents a single Setting resource from the K8s API.
type settingResource struct {
Spec Setting `json:"spec"`
}
// settingListMetadata contains pagination info from the K8s list response.
type settingListMetadata struct {
Continue string `json:"continue,omitempty"`
}
// New creates a Service from the provided configuration.
func New(config Config) (Service, error) {
log := logging.New(LogPrefix)
dynamicClient, err := getDynamicClient(config, log)
restClient, err := getRestClient(config, log)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create REST client: %w", err)
}
pageSize := DefaultPageSize
if config.PageSize > 0 {
pageSize = config.PageSize
}
metrics := initMetrics()
return &remoteSettingService{
dynamicClient: dynamicClient,
pageSize: pageSize,
log: log,
metrics: metrics,
restClient: restClient,
log: log,
pageSize: pageSize,
metrics: initMetrics(),
}, nil
}
func (m *remoteSettingService) ListAsIni(ctx context.Context, labelSelector metav1.LabelSelector) (*ini.File, error) {
func (s *remoteSettingService) ListAsIni(ctx context.Context, labelSelector metav1.LabelSelector) (*ini.File, error) {
namespace, ok := request.NamespaceFrom(ctx)
ns := semconv.GrafanaNamespaceName(namespace)
ctx, span := tracer.Start(ctx, "remoteSettingService.ListAsIni",
@@ -178,33 +180,34 @@ func (m *remoteSettingService) ListAsIni(ctx context.Context, labelSelector meta
return nil, tracing.Errorf(span, "missing namespace in context")
}
settings, err := m.List(ctx, labelSelector)
settings, err := s.List(ctx, labelSelector)
if err != nil {
return nil, err
}
iniFile, err := m.toIni(settings)
iniFile, err := toIni(settings)
if err != nil {
return nil, tracing.Error(span, err)
}
return iniFile, nil
}
func (m *remoteSettingService) List(ctx context.Context, labelSelector metav1.LabelSelector) ([]*Setting, error) {
func (s *remoteSettingService) List(ctx context.Context, labelSelector metav1.LabelSelector) ([]*Setting, error) {
namespace, ok := request.NamespaceFrom(ctx)
ns := semconv.GrafanaNamespaceName(namespace)
ctx, span := tracer.Start(ctx, "remoteSettingService.List",
trace.WithAttributes(ns))
defer span.End()
if !ok || namespace == "" {
return nil, tracing.Errorf(span, "missing namespace in context")
}
log := m.log.FromContext(ctx).New(ns.Key, ns.Value, "function", "remoteSettingService.List", "traceId", span.SpanContext().TraceID())
log := s.log.FromContext(ctx).New(ns.Key, ns.Value, "function", "remoteSettingService.List", "traceId", span.SpanContext().TraceID())
startTime := time.Now()
var status string
defer func() {
duration := time.Since(startTime).Seconds()
m.metrics.listDuration.WithLabelValues(status).Observe(duration)
s.metrics.listDuration.WithLabelValues(status).Observe(duration)
}()
selector, err := metav1.LabelSelectorAsSelector(&labelSelector)
@@ -216,64 +219,142 @@ func (m *remoteSettingService) List(ctx context.Context, labelSelector metav1.La
log.Debug("empty selector. Fetching all settings")
}
var allSettings []*Setting
// Pre-allocate with estimated capacity
allSettings := make([]*Setting, 0, s.pageSize*8)
var continueToken string
hasNext := true
totalPages := 0
// Using an upper limit to prevent infinite loops
for hasNext && totalPages < 1000 {
totalPages++
opts := metav1.ListOptions{
Limit: m.pageSize,
Continue: continueToken,
}
if !selector.Empty() {
opts.LabelSelector = selector.String()
}
settingsList, lErr := m.dynamicClient.Resource(settingGroupVersion).Namespace(namespace).List(ctx, opts)
settings, nextToken, lErr := s.fetchPage(ctx, namespace, selector.String(), continueToken)
if lErr != nil {
status = "error"
return nil, tracing.Error(span, lErr)
}
for i := range settingsList.Items {
setting, pErr := parseSettingResource(&settingsList.Items[i])
if pErr != nil {
status = "error"
return nil, tracing.Error(span, pErr)
}
allSettings = append(allSettings, setting)
}
continueToken = settingsList.GetContinue()
allSettings = append(allSettings, settings...)
continueToken = nextToken
if continueToken == "" {
hasNext = false
}
}
status = "success"
m.metrics.listResultSize.WithLabelValues(status).Observe(float64(len(allSettings)))
s.metrics.listResultSize.WithLabelValues(status).Observe(float64(len(allSettings)))
return allSettings, nil
}
func parseSettingResource(setting *unstructured.Unstructured) (*Setting, error) {
spec, found, err := unstructured.NestedMap(setting.Object, "spec")
func (s *remoteSettingService) fetchPage(ctx context.Context, namespace, labelSelector, continueToken string) ([]*Setting, string, error) {
req := s.restClient.Get().
Resource(resource).
Namespace(namespace).
Param("limit", fmt.Sprintf("%d", s.pageSize))
if labelSelector != "" {
req = req.Param("labelSelector", labelSelector)
}
if continueToken != "" {
req = req.Param("continue", continueToken)
}
stream, err := req.Stream(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get spec from setting: %w", err)
}
if !found {
return nil, fmt.Errorf("spec not found in setting %s", setting.GetName())
return nil, "", fmt.Errorf("request failed: %w", err)
}
defer func() { _ = stream.Close() }()
var result Setting
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(spec, &result); err != nil {
return nil, fmt.Errorf("failed to convert spec to Setting: %w", err)
}
return &result, nil
return parseSettingList(stream)
}
func (m *remoteSettingService) toIni(settings []*Setting) (*ini.File, error) {
// parseSettingList parses a SettingList JSON response using token-by-token streaming.
func parseSettingList(r io.Reader) ([]*Setting, string, error) {
decoder := json.NewDecoder(r)
// Currently, first page may have a large number of items.
settings := make([]*Setting, 0, 1600)
var continueToken string
// Skip to the start of the object
if _, err := decoder.Token(); err != nil {
return nil, "", fmt.Errorf("expected start of object: %w", err)
}
for decoder.More() {
// Read field name
tok, err := decoder.Token()
if err != nil {
return nil, "", fmt.Errorf("failed to read field name: %w", err)
}
fieldName, ok := tok.(string)
if !ok {
continue
}
switch fieldName {
case "metadata":
var meta settingListMetadata
if err := decoder.Decode(&meta); err != nil {
return nil, "", fmt.Errorf("failed to decode metadata: %w", err)
}
continueToken = meta.Continue
case "items":
// Parse items array token-by-token
itemSettings, err := parseItems(decoder)
if err != nil {
return nil, "", err
}
settings = append(settings, itemSettings...)
default:
// Skip unknown fields
var skip json.RawMessage
if err := decoder.Decode(&skip); err != nil {
return nil, "", fmt.Errorf("failed to skip field %s: %w", fieldName, err)
}
}
}
return settings, continueToken, nil
}
func parseItems(decoder *json.Decoder) ([]*Setting, error) {
// Expect start of array
tok, err := decoder.Token()
if err != nil {
return nil, fmt.Errorf("expected start of items array: %w", err)
}
if tok != json.Delim('[') {
return nil, fmt.Errorf("expected '[', got %v", tok)
}
settings := make([]*Setting, 0, DefaultPageSize)
// Parse each item
for decoder.More() {
var item settingResource
if err := decoder.Decode(&item); err != nil {
return nil, fmt.Errorf("failed to decode setting item: %w", err)
}
settings = append(settings, &Setting{
Section: item.Spec.Section,
Key: item.Spec.Key,
Value: item.Spec.Value,
})
}
// Consume end of array
if _, err := decoder.Token(); err != nil {
return nil, fmt.Errorf("expected end of items array: %w", err)
}
return settings, nil
}
func toIni(settings []*Setting) (*ini.File, error) {
conf := ini.Empty()
for _, setting := range settings {
if !conf.HasSection(setting.Section) {
@@ -287,7 +368,7 @@ func (m *remoteSettingService) toIni(settings []*Setting) (*ini.File, error) {
return conf, nil
}
func getDynamicClient(config Config, log logging.Logger) (dynamic.Interface, error) {
func getRestClient(config Config, log logging.Logger) (*rest.RESTClient, error) {
if config.URL == "" {
return nil, fmt.Errorf("URL cannot be empty")
}
@@ -296,7 +377,7 @@ func getDynamicClient(config Config, log logging.Logger) (dynamic.Interface, err
}
wrapTransport := config.WrapTransport
if config.WrapTransport == nil {
if wrapTransport == nil {
log.Debug("using default wrapTransport with TokenExchangeClient")
wrapTransport = func(rt http.RoundTripper) http.RoundTripper {
return &authRoundTripper{
@@ -316,13 +397,21 @@ func getDynamicClient(config Config, log logging.Logger) (dynamic.Interface, err
burst = config.Burst
}
return dynamic.NewForConfig(&clientrest.Config{
restConfig := &rest.Config{
Host: config.URL,
WrapTransport: wrapTransport,
TLSClientConfig: config.TLSClientConfig,
WrapTransport: wrapTransport,
QPS: qps,
Burst: burst,
})
// Configure for our API group
APIPath: "/apis",
ContentConfig: rest.ContentConfig{
GroupVersion: &settingGroupVersion,
NegotiatedSerializer: serializer.NewCodecFactory(nil).WithoutConversion(),
},
}
return rest.RESTClientFor(restConfig)
}
// authRoundTripper wraps an HTTP transport with token-based authentication.
@@ -341,10 +430,9 @@ func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
if err != nil {
return nil, fmt.Errorf("failed to exchange token: %w", err)
}
req = utilnet.CloneRequest(req)
req.Header.Set("X-Access-Token", fmt.Sprintf("Bearer %s", token.Token))
return a.transport.RoundTrip(req)
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("X-Access-Token", fmt.Sprintf("Bearer %s", token.Token))
return a.transport.RoundTrip(reqCopy)
}
func initMetrics() remoteSettingServiceMetrics {
@@ -373,12 +461,12 @@ func initMetrics() remoteSettingServiceMetrics {
return metrics
}
func (m *remoteSettingService) Describe(descs chan<- *prometheus.Desc) {
m.metrics.listDuration.Describe(descs)
m.metrics.listResultSize.Describe(descs)
func (s *remoteSettingService) Describe(descs chan<- *prometheus.Desc) {
s.metrics.listDuration.Describe(descs)
s.metrics.listResultSize.Describe(descs)
}
func (m *remoteSettingService) Collect(metrics chan<- prometheus.Metric) {
m.metrics.listDuration.Collect(metrics)
m.metrics.listResultSize.Collect(metrics)
func (s *remoteSettingService) Collect(metrics chan<- prometheus.Metric) {
s.metrics.listDuration.Collect(metrics)
s.metrics.listResultSize.Collect(metrics)
}
+233 -348
View File
@@ -1,69 +1,36 @@
package setting
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/dynamic/fake"
k8testing "k8s.io/client-go/testing"
authlib "github.com/grafana/authlib/authn"
"github.com/grafana/grafana/pkg/infra/log"
)
func TestRemoteSettingService_ListAsIni(t *testing.T) {
t.Run("should filter settings by label selector", func(t *testing.T) {
// Create multiple settings, only some matching the selector
setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"})
setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
setting3 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "host", Value: "localhost"})
client := newTestClient(500, setting1, setting2, setting3)
// Create a selector that should match only database settings
selector := metav1.LabelSelector{
MatchLabels: map[string]string{
"section": "database",
},
}
ctx := request.WithNamespace(context.Background(), "test-namespace")
result, err := client.ListAsIni(ctx, selector)
require.NoError(t, err)
assert.NotNil(t, result)
// Should only have database settings, not server settings
assert.True(t, result.HasSection("database"))
assert.Equal(t, "postgres", result.Section("database").Key("type").String())
assert.Equal(t, "localhost", result.Section("database").Key("host").String())
// Should NOT have server settings
assert.False(t, result.HasSection("server"))
})
t.Run("should return all settings with empty selector", func(t *testing.T) {
// Create multiple settings across different sections
setting1 := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
setting2 := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "mysql"})
client := newTestClient(500, setting1, setting2)
// Empty selector should select everything
selector := metav1.LabelSelector{}
settings := []Setting{
{Section: "server", Key: "port", Value: "3000"},
{Section: "database", Key: "type", Value: "mysql"},
}
server := newTestServer(t, settings, "")
defer server.Close()
client := newTestClient(t, server.URL, 500)
ctx := request.WithNamespace(context.Background(), "test-namespace")
result, err := client.ListAsIni(ctx, selector)
result, err := client.ListAsIni(ctx, metav1.LabelSelector{})
require.NoError(t, err)
assert.NotNil(t, result)
// Should have all settings from all sections
assert.True(t, result.HasSection("server"))
assert.Equal(t, "3000", result.Section("server").Key("port").String())
assert.True(t, result.HasSection("database"))
@@ -73,209 +40,168 @@ func TestRemoteSettingService_ListAsIni(t *testing.T) {
func TestRemoteSettingService_List(t *testing.T) {
t.Run("should handle single page response", func(t *testing.T) {
setting := newUnstructuredSetting("test-namespace", Setting{Section: "server", Key: "port", Value: "3000"})
client := newTestClient(500, setting)
settings := []Setting{
{Section: "server", Key: "port", Value: "3000"},
}
server := newTestServer(t, settings, "")
defer server.Close()
client := newTestClient(t, server.URL, 500)
ctx := request.WithNamespace(context.Background(), "test-namespace")
result, err := client.List(ctx, metav1.LabelSelector{})
require.NoError(t, err)
assert.Len(t, result, 1)
spec := result[0]
assert.Equal(t, "server", spec.Section)
assert.Equal(t, "port", spec.Key)
assert.Equal(t, "3000", spec.Value)
assert.Equal(t, "server", result[0].Section)
assert.Equal(t, "port", result[0].Key)
assert.Equal(t, "3000", result[0].Value)
})
t.Run("should handle multiple pages", func(t *testing.T) {
totalPages := 3
pageSize := 5
pages := make([][]*unstructured.Unstructured, totalPages)
for pageNum := 0; pageNum < totalPages; pageNum++ {
for idx := 0; idx < pageSize; idx++ {
item := newUnstructuredSetting(
"test-namespace",
Setting{
Section: fmt.Sprintf("section-%d", pageNum),
Key: fmt.Sprintf("key-%d", idx),
Value: fmt.Sprintf("val-%d-%d", pageNum, idx),
},
)
pages[pageNum] = append(pages[pageNum], item)
}
}
scheme := runtime.NewScheme()
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
listCallCount := 0
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
listCallCount++
continueToken := fmt.Sprintf("continue-%d", listCallCount)
if listCallCount == totalPages {
continueToken = ""
}
if listCallCount <= totalPages {
list := &unstructured.UnstructuredList{
Object: map[string]interface{}{
"apiVersion": ApiGroup + "/" + apiVersion,
"kind": listKind,
},
}
list.SetContinue(continueToken)
for _, item := range pages[listCallCount-1] {
list.Items = append(list.Items, *item)
}
return true, list, nil
}
return false, nil, nil
})
client := &remoteSettingService{
dynamicClient: dynamicClient,
pageSize: int64(pageSize),
log: log.NewNopLogger(),
metrics: initMetrics(),
t.Run("should handle multiple settings", func(t *testing.T) {
settings := []Setting{
{Section: "server", Key: "port", Value: "3000"},
{Section: "database", Key: "host", Value: "localhost"},
{Section: "database", Key: "port", Value: "5432"},
}
server := newTestServer(t, settings, "")
defer server.Close()
client := newTestClient(t, server.URL, 500)
ctx := request.WithNamespace(context.Background(), "test-namespace")
result, err := client.List(ctx, metav1.LabelSelector{})
require.NoError(t, err)
assert.Len(t, result, totalPages*pageSize)
assert.Equal(t, totalPages, listCallCount)
assert.Len(t, result, 3)
})
t.Run("should pass label selector when provided", func(t *testing.T) {
scheme := runtime.NewScheme()
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
listAction := action.(k8testing.ListActionImpl)
assert.Equal(t, "app=grafana", listAction.ListOptions.LabelSelector)
return true, &unstructured.UnstructuredList{}, nil
})
client := &remoteSettingService{
dynamicClient: dynamicClient,
pageSize: 500,
log: log.NewNopLogger(),
metrics: initMetrics(),
t.Run("should handle pagination with continue token", func(t *testing.T) {
// First page
page1Settings := []Setting{
{Section: "section-0", Key: "key-0", Value: "value-0"},
{Section: "section-0", Key: "key-1", Value: "value-1"},
}
// Second page
page2Settings := []Setting{
{Section: "section-1", Key: "key-0", Value: "value-2"},
{Section: "section-1", Key: "key-1", Value: "value-3"},
}
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
continueToken := r.URL.Query().Get("continue")
var settings []Setting
var nextContinue string
if continueToken == "" {
settings = page1Settings
nextContinue = "page2"
} else {
settings = page2Settings
nextContinue = ""
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(generateSettingsJSON(settings, nextContinue)))
}))
defer server.Close()
client := newTestClient(t, server.URL, 2)
ctx := request.WithNamespace(context.Background(), "test-namespace")
_, err := client.List(ctx, metav1.LabelSelector{MatchLabels: map[string]string{"app": "grafana"}})
result, err := client.List(ctx, metav1.LabelSelector{})
require.NoError(t, err)
assert.Len(t, result, 4)
assert.Equal(t, 2, requestCount)
})
t.Run("should stop pagination at 1000 pages", func(t *testing.T) {
scheme := runtime.NewScheme()
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
listCallCount := 0
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
listCallCount++
// Always return a continue token to simulate infinite pagination
list := &unstructured.UnstructuredList{}
list.SetContinue("continue-forever")
return true, list, nil
})
t.Run("should return error when namespace is missing", func(t *testing.T) {
server := newTestServer(t, nil, "")
defer server.Close()
client := &remoteSettingService{
dynamicClient: dynamicClient,
pageSize: 10,
log: log.NewNopLogger(),
metrics: initMetrics(),
}
client := newTestClient(t, server.URL, 500)
ctx := context.Background() // No namespace
ctx := request.WithNamespace(context.Background(), "test-namespace")
_, err := client.List(ctx, metav1.LabelSelector{})
require.NoError(t, err)
assert.Equal(t, 1000, listCallCount, "Should stop at 1000 pages to prevent infinite loops")
})
t.Run("should return error when parsing setting fails", func(t *testing.T) {
scheme := runtime.NewScheme()
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind)
dynamicClient.PrependReactor("list", "settings", func(action k8testing.Action) (handled bool, ret runtime.Object, err error) {
// Return a malformed setting without spec
list := &unstructured.UnstructuredList{
Object: map[string]interface{}{
"apiVersion": ApiGroup + "/" + apiVersion,
"kind": listKind,
},
}
malformedSetting := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": ApiGroup + "/" + apiVersion,
"kind": kind,
"metadata": map[string]interface{}{
"name": "malformed",
"namespace": "test-namespace",
},
// Missing spec
},
}
list.Items = append(list.Items, *malformedSetting)
return true, list, nil
})
client := &remoteSettingService{
dynamicClient: dynamicClient,
pageSize: 500,
log: log.NewNopLogger(),
metrics: initMetrics(),
}
ctx := request.WithNamespace(context.Background(), "test-namespace")
result, err := client.List(ctx, metav1.LabelSelector{})
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "spec not found")
})
}
func TestParseSettingResource(t *testing.T) {
t.Run("should parse valid setting resource", func(t *testing.T) {
setting := newUnstructuredSetting("test-namespace", Setting{Section: "database", Key: "type", Value: "postgres"})
result, err := parseSettingResource(setting)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "database", result.Section)
assert.Equal(t, "type", result.Key)
assert.Equal(t, "postgres", result.Value)
assert.Contains(t, err.Error(), "missing namespace")
})
t.Run("should return error when spec is missing", func(t *testing.T) {
setting := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": ApiGroup + "/" + apiVersion,
"kind": kind,
"metadata": map[string]interface{}{
"name": "test-setting",
"namespace": "test-namespace",
},
// No spec
},
}
t.Run("should return error on HTTP error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("internal server error"))
}))
defer server.Close()
result, err := parseSettingResource(setting)
client := newTestClient(t, server.URL, 500)
ctx := request.WithNamespace(context.Background(), "test-namespace")
result, err := client.List(ctx, metav1.LabelSelector{})
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "spec not found")
})
}
func TestRemoteSettingService_ToIni(t *testing.T) {
func TestParseSettingList(t *testing.T) {
t.Run("should parse valid settings list", func(t *testing.T) {
jsonData := `{
"apiVersion": "setting.grafana.app/v0alpha1",
"kind": "SettingList",
"metadata": {"continue": ""},
"items": [
{"spec": {"section": "database", "key": "type", "value": "postgres"}},
{"spec": {"section": "server", "key": "port", "value": "3000"}}
]
}`
settings, continueToken, err := parseSettingList(strings.NewReader(jsonData))
require.NoError(t, err)
assert.Len(t, settings, 2)
assert.Equal(t, "", continueToken)
assert.Equal(t, "database", settings[0].Section)
assert.Equal(t, "type", settings[0].Key)
assert.Equal(t, "postgres", settings[0].Value)
})
t.Run("should parse continue token", func(t *testing.T) {
jsonData := `{
"apiVersion": "setting.grafana.app/v0alpha1",
"kind": "SettingList",
"metadata": {"continue": "next-page-token"},
"items": []
}`
_, continueToken, err := parseSettingList(strings.NewReader(jsonData))
require.NoError(t, err)
assert.Equal(t, "next-page-token", continueToken)
})
t.Run("should handle empty items", func(t *testing.T) {
jsonData := `{
"apiVersion": "setting.grafana.app/v0alpha1",
"kind": "SettingList",
"metadata": {},
"items": []
}`
settings, _, err := parseSettingList(strings.NewReader(jsonData))
require.NoError(t, err)
assert.Len(t, settings, 0)
})
}
func TestToIni(t *testing.T) {
t.Run("should convert settings to ini format", func(t *testing.T) {
settings := []*Setting{
{Section: "database", Key: "type", Value: "postgres"},
@@ -283,12 +209,7 @@ func TestRemoteSettingService_ToIni(t *testing.T) {
{Section: "server", Key: "http_port", Value: "3000"},
}
client := &remoteSettingService{
pageSize: 500,
log: log.NewNopLogger(),
}
result, err := client.toIni(settings)
result, err := toIni(settings)
require.NoError(t, err)
assert.NotNil(t, result)
@@ -302,12 +223,7 @@ func TestRemoteSettingService_ToIni(t *testing.T) {
t.Run("should handle empty settings list", func(t *testing.T) {
var settings []*Setting
client := &remoteSettingService{
pageSize: 500,
log: log.NewNopLogger(),
}
result, err := client.toIni(settings)
result, err := toIni(settings)
require.NoError(t, err)
assert.NotNil(t, result)
@@ -315,35 +231,13 @@ func TestRemoteSettingService_ToIni(t *testing.T) {
assert.Len(t, sections, 1) // Only default section
})
t.Run("should create section if it does not exist", func(t *testing.T) {
settings := []*Setting{
{Section: "new_section", Key: "new_key", Value: "new_value"},
}
client := &remoteSettingService{
pageSize: 500,
log: log.NewNopLogger(),
}
result, err := client.toIni(settings)
require.NoError(t, err)
assert.True(t, result.HasSection("new_section"))
assert.Equal(t, "new_value", result.Section("new_section").Key("new_key").String())
})
t.Run("should handle multiple keys in same section", func(t *testing.T) {
settings := []*Setting{
{Section: "auth", Key: "disable_login_form", Value: "false"},
{Section: "auth", Key: "disable_signout_menu", Value: "true"},
}
client := &remoteSettingService{
pageSize: 500,
log: log.NewNopLogger(),
}
result, err := client.toIni(settings)
result, err := toIni(settings)
require.NoError(t, err)
assert.True(t, result.HasSection("auth"))
@@ -383,24 +277,23 @@ func TestNew(t *testing.T) {
assert.Equal(t, int64(100), remoteClient.pageSize)
})
t.Run("should use default page size when zero is provided", func(t *testing.T) {
t.Run("should create client with custom QPS and Burst", func(t *testing.T) {
config := Config{
URL: "https://example.com",
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
PageSize: 0,
QPS: 50.0,
Burst: 100,
}
client, err := New(config)
require.NoError(t, err)
assert.NotNil(t, client)
remoteClient := client.(*remoteSettingService)
assert.Equal(t, DefaultPageSize, remoteClient.pageSize)
})
t.Run("should return error when config is invalid", func(t *testing.T) {
t.Run("should return error when URL is empty", func(t *testing.T) {
config := Config{
URL: "", // Invalid: empty URL
URL: "",
}
client, err := New(config)
@@ -409,134 +302,126 @@ func TestNew(t *testing.T) {
assert.Nil(t, client)
assert.Contains(t, err.Error(), "URL cannot be empty")
})
}
func TestGetDynamicClient(t *testing.T) {
logger := log.NewNopLogger()
t.Run("should return error when SettingServiceURL is empty", func(t *testing.T) {
config := Config{
URL: "",
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
}
client, err := getDynamicClient(config, logger)
require.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "URL cannot be empty")
})
t.Run("should return error when both TokenExchangeClient and WrapTransport are nil", func(t *testing.T) {
t.Run("should return error when auth is not configured", func(t *testing.T) {
config := Config{
URL: "https://example.com",
TokenExchangeClient: nil,
WrapTransport: nil,
}
client, err := getDynamicClient(config, logger)
client, err := New(config)
require.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "must set either TokenExchangeClient or WrapTransport")
})
t.Run("should create client with WrapTransport", func(t *testing.T) {
config := Config{
URL: "https://example.com",
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
}
client, err := getDynamicClient(config, logger)
require.NoError(t, err)
assert.NotNil(t, client)
})
t.Run("should not fail when QPS and Burst are not provided", func(t *testing.T) {
config := Config{
URL: "https://example.com",
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
}
client, err := getDynamicClient(config, logger)
require.NoError(t, err)
assert.NotNil(t, client)
})
t.Run("should not fail when custom QPS and Burst are provided", func(t *testing.T) {
config := Config{
URL: "https://example.com",
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
QPS: 10.0,
Burst: 20,
}
client, err := getDynamicClient(config, logger)
require.NoError(t, err)
assert.NotNil(t, client)
})
t.Run("should use WrapTransport when both WrapTransport and TokenExchangeClient are provided", func(t *testing.T) {
t.Run("should use WrapTransport when provided", func(t *testing.T) {
wrapTransportCalled := false
tokenExchangeClient := &authlib.TokenExchangeClient{}
config := Config{
URL: "https://example.com",
TokenExchangeClient: tokenExchangeClient,
URL: "https://example.com",
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
wrapTransportCalled = true
return rt
},
}
client, err := getDynamicClient(config, logger)
client, err := New(config)
require.NoError(t, err)
assert.NotNil(t, client)
assert.True(t, wrapTransportCalled, "WrapTransport should be called and take precedence over TokenExchangeClient")
assert.True(t, wrapTransportCalled)
})
}
// Helper function to create an unstructured Setting object for tests
func newUnstructuredSetting(namespace string, spec Setting) *unstructured.Unstructured {
// Generate resource name in the format {section}--{key}
name := fmt.Sprintf("%s--%s", spec.Section, spec.Key)
// Helper functions
obj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": ApiGroup + "/" + apiVersion,
"kind": kind,
"metadata": map[string]interface{}{
"name": name,
"namespace": namespace,
},
"spec": map[string]interface{}{
"section": spec.Section,
"key": spec.Key,
"value": spec.Value,
},
},
}
// Always set section and key labels
obj.SetLabels(map[string]string{
"section": spec.Section,
"key": spec.Key,
})
return obj
func newTestServer(t *testing.T, settings []Setting, continueToken string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(generateSettingsJSON(settings, continueToken)))
}))
}
// Helper function to create a test client with the dynamic fake client
func newTestClient(pageSize int64, objects ...runtime.Object) *remoteSettingService {
scheme := runtime.NewScheme()
dynamicClient := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, settingGroupListKind, objects...)
func newTestClient(t *testing.T, serverURL string, pageSize int64) Service {
t.Helper()
config := Config{
URL: serverURL,
WrapTransport: func(rt http.RoundTripper) http.RoundTripper { return rt },
PageSize: pageSize,
}
client, err := New(config)
require.NoError(t, err)
return client
}
return &remoteSettingService{
dynamicClient: dynamicClient,
pageSize: pageSize,
log: log.NewNopLogger(),
metrics: initMetrics(),
func generateSettingsJSON(settings []Setting, continueToken string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(`{"apiVersion":"setting.grafana.app/v0alpha1","kind":"SettingList","metadata":{"continue":"%s"},"items":[`, continueToken))
for i, s := range settings {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(fmt.Sprintf(
`{"apiVersion":"setting.grafana.app/v0alpha1","kind":"Setting","metadata":{"name":"%s--%s","namespace":"test-namespace"},"spec":{"section":"%s","key":"%s","value":"%s"}}`,
s.Section, s.Key, s.Section, s.Key, s.Value,
))
}
sb.WriteString(`]}`)
return sb.String()
}
// Benchmark tests for streaming JSON parser
func BenchmarkParseSettingList(b *testing.B) {
jsonData := generateSettingListJSON(4000, 100)
jsonBytes := []byte(jsonData)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
reader := bytes.NewReader(jsonBytes)
_, _, _ = parseSettingList(reader)
}
}
func BenchmarkParseSettingList_SinglePage(b *testing.B) {
jsonData := generateSettingListJSON(500, 50)
jsonBytes := []byte(jsonData)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
reader := bytes.NewReader(jsonBytes)
_, _, _ = parseSettingList(reader)
}
}
// generateSettingListJSON generates a K8s-style SettingList JSON response for benchmarks
func generateSettingListJSON(totalSettings, numSections int) string {
var sb strings.Builder
sb.WriteString(`{"apiVersion":"setting.grafana.app/v0alpha1","kind":"SettingList","metadata":{"continue":""},"items":[`)
settingsPerSection := totalSettings / numSections
first := true
for section := 0; section < numSections; section++ {
for key := 0; key < settingsPerSection; key++ {
if !first {
sb.WriteString(",")
}
first = false
sb.WriteString(fmt.Sprintf(
`{"apiVersion":"setting.grafana.app/v0alpha1","kind":"Setting","metadata":{"name":"section-%03d--key-%03d","namespace":"bench-ns"},"spec":{"section":"section-%03d","key":"key-%03d","value":"value-for-section-%d-key-%d"}}`,
section, key, section, key, section, key,
))
}
}
sb.WriteString(`]}`)
return sb.String()
}
@@ -165,5 +165,7 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
ualert.AddStateAnnotationsColumn(mg)
ualert.CollateBinAlertRuleNamespace(mg)
ualert.CollateBinAlertRuleGroup(mg)
}
@@ -0,0 +1,10 @@
package ualert
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
// CollateBinAlertRuleNamespace ensures that namespace_uid column collates in the same way go sorts strings.
func CollateBinAlertRuleNamespace(mg *migrator.Migrator) {
mg.AddMigration("ensure namespace_uid column sorts the same way as golang", migrator.NewRawSQLMigration("").
Mysql("ALTER TABLE alert_rule MODIFY namespace_uid VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;").
Postgres(`ALTER TABLE alert_rule ALTER COLUMN namespace_uid SET DATA TYPE varchar(40) COLLATE "C";`))
}
@@ -22,7 +22,6 @@ const featureIni = `# In your custom.ini file
[feature_toggles]
provisioning = true
kubernetesDashboards = true ; use k8s from browser
`;
const ngrokExample = `ngrok http 3000
@@ -103,7 +102,7 @@ const getModalContent = (setupType: SetupType) => {
),
description: t(
'provisioning.getting-started.step-description-enable-feature-toggles',
'Add these settings to your custom.ini file to enable necessary features:'
'Add the provisioning feature toggle to your custom.ini file. Note: kubernetesDashboards is enabled by default, but if you have explicitly disabled it, you will need to enable it in your Grafana settings or remove the override from your configuration.'
),
code: featureIni,
},
+289 -14
View File
@@ -22,8 +22,8 @@ describe('ScopesService', () => {
| undefined;
let dashboardsStateSubscription:
| ((
state: { navigationScope?: string; drawerOpened: boolean },
prevState: { navigationScope?: string; drawerOpened: boolean }
state: { navigationScope?: string; drawerOpened: boolean; navScopePath?: string[] },
prevState: { navigationScope?: string; drawerOpened: boolean; navScopePath?: string[] }
) => void)
| undefined;
@@ -56,7 +56,7 @@ describe('ScopesService', () => {
selectorStateSubscription = callback;
return { unsubscribe: jest.fn() };
}),
changeScopes: jest.fn(),
changeScopes: jest.fn().mockResolvedValue(undefined),
resolvePathToRoot: jest.fn().mockResolvedValue({ path: [], tree: {} }),
} as unknown as jest.Mocked<ScopesSelectorService>;
@@ -71,6 +71,7 @@ describe('ScopesService', () => {
loading: false,
searchQuery: '',
navigationScope: undefined,
navScopePath: undefined,
},
stateObservable: new BehaviorSubject({
drawerOpened: false,
@@ -82,12 +83,14 @@ describe('ScopesService', () => {
loading: false,
searchQuery: '',
navigationScope: undefined,
navScopePath: undefined,
}),
subscribeToState: jest.fn((callback) => {
dashboardsStateSubscription = callback;
return { unsubscribe: jest.fn() };
}),
setNavigationScope: jest.fn(),
setNavScopePath: jest.fn(),
} as unknown as jest.Mocked<ScopesDashboardsService>;
locationService = {
@@ -188,7 +191,7 @@ describe('ScopesService', () => {
service = new ScopesService(selectorService, dashboardsService, locationService);
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1');
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, undefined);
});
it('should read navigation_scope along with other scope parameters', () => {
@@ -199,7 +202,7 @@ describe('ScopesService', () => {
service = new ScopesService(selectorService, dashboardsService, locationService);
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1');
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, undefined);
expect(selectorService.changeScopes).toHaveBeenCalledWith(['scope1'], undefined, 'node1', false);
});
@@ -213,6 +216,45 @@ describe('ScopesService', () => {
expect(dashboardsService.setNavigationScope).not.toHaveBeenCalled();
});
it('should read nav_scope_path along with navigation_scope from URL on init', () => {
locationService.getLocation = jest.fn().mockReturnValue({
pathname: '/test',
search: '?navigation_scope=navScope1&nav_scope_path=mimir%2Cloki',
});
service = new ScopesService(selectorService, dashboardsService, locationService);
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, ['mimir', 'loki']);
});
it('should handle nav_scope_path without navigation_scope by calling setNavScopePath after changeScopes', async () => {
locationService.getLocation = jest.fn().mockReturnValue({
pathname: '/test',
search: '?scopes=scope1&nav_scope_path=mimir',
});
service = new ScopesService(selectorService, dashboardsService, locationService);
// Wait for the changeScopes promise to resolve
await Promise.resolve();
expect(dashboardsService.setNavScopePath).toHaveBeenCalledWith(['mimir']);
});
it('should handle URL-encoded nav_scope_path values', () => {
locationService.getLocation = jest.fn().mockReturnValue({
pathname: '/test',
search: '?navigation_scope=navScope1&nav_scope_path=' + encodeURIComponent('folder one,folder two'),
});
service = new ScopesService(selectorService, dashboardsService, locationService);
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, [
'folder one',
'folder two',
]);
});
});
describe('URL synchronization', () => {
@@ -346,16 +388,22 @@ describe('ScopesService', () => {
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: undefined,
},
{
navigationScope: undefined,
drawerOpened: false,
navScopePath: undefined,
}
);
expect(locationService.partial).toHaveBeenCalledWith({
navigation_scope: 'navScope1',
});
expect(locationService.partial).toHaveBeenCalledWith(
{
navigation_scope: 'navScope1',
nav_scope_path: null,
},
true
);
});
it('should update navigation_scope in URL when navigationScope changes', () => {
@@ -367,16 +415,22 @@ describe('ScopesService', () => {
{
navigationScope: 'navScope2',
drawerOpened: true,
navScopePath: undefined,
},
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: undefined,
}
);
expect(locationService.partial).toHaveBeenCalledWith({
navigation_scope: 'navScope2',
});
expect(locationService.partial).toHaveBeenCalledWith(
{
navigation_scope: 'navScope2',
nav_scope_path: null,
},
true
);
});
it('should not update URL when navigationScope has not changed', () => {
@@ -390,10 +444,12 @@ describe('ScopesService', () => {
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: undefined,
},
{
navigationScope: 'navScope1',
drawerOpened: false,
navScopePath: undefined,
}
);
@@ -409,16 +465,126 @@ describe('ScopesService', () => {
{
navigationScope: undefined,
drawerOpened: false,
navScopePath: undefined,
},
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: undefined,
}
);
expect(locationService.partial).toHaveBeenCalledWith({
navigation_scope: undefined,
});
expect(locationService.partial).toHaveBeenCalledWith(
{
navigation_scope: null,
nav_scope_path: null,
},
true
);
});
it('should write nav_scope_path to URL when navScopePath changes', () => {
if (!dashboardsStateSubscription) {
throw new Error('dashboardsStateSubscription not set');
}
dashboardsStateSubscription(
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: ['mimir', 'loki'],
},
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: undefined,
}
);
expect(locationService.partial).toHaveBeenCalledWith(
{
navigation_scope: 'navScope1',
nav_scope_path: encodeURIComponent('mimir,loki'),
},
true
);
});
it('should update nav_scope_path in URL when navScopePath changes', () => {
if (!dashboardsStateSubscription) {
throw new Error('dashboardsStateSubscription not set');
}
dashboardsStateSubscription(
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: ['mimir', 'loki', 'tempo'],
},
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: ['mimir', 'loki'],
}
);
expect(locationService.partial).toHaveBeenCalledWith(
{
navigation_scope: 'navScope1',
nav_scope_path: encodeURIComponent('mimir,loki,tempo'),
},
true
);
});
it('should clear nav_scope_path from URL when navScopePath becomes empty', () => {
if (!dashboardsStateSubscription) {
throw new Error('dashboardsStateSubscription not set');
}
dashboardsStateSubscription(
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: [],
},
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: ['mimir'],
}
);
expect(locationService.partial).toHaveBeenCalledWith(
{
navigation_scope: 'navScope1',
nav_scope_path: null,
},
true
);
});
it('should not update URL when only drawerOpened changes but navigationScope and navScopePath remain the same', () => {
if (!dashboardsStateSubscription) {
throw new Error('dashboardsStateSubscription not set');
}
jest.clearAllMocks();
dashboardsStateSubscription(
{
navigationScope: 'navScope1',
drawerOpened: false,
navScopePath: ['mimir'],
},
{
navigationScope: 'navScope1',
drawerOpened: true,
navScopePath: ['mimir'],
}
);
expect(locationService.partial).not.toHaveBeenCalled();
});
});
@@ -457,4 +623,113 @@ describe('ScopesService', () => {
);
});
});
describe('back/forward navigation handling', () => {
let locationSubject: BehaviorSubject<{ pathname: string; search: string }>;
beforeEach(() => {
locationSubject = new BehaviorSubject({
pathname: '/test',
search: '',
});
locationService.getLocation = jest.fn().mockReturnValue({
pathname: '/test',
search: '',
});
locationService.getLocationObservable = jest.fn().mockReturnValue(locationSubject);
// Set initial state for dashboards service
dashboardsService.state.navigationScope = undefined;
dashboardsService.state.navScopePath = undefined;
service = new ScopesService(selectorService, dashboardsService, locationService);
service.setEnabled(true);
jest.clearAllMocks();
});
it('should update navigation scope when URL changes via back/forward', () => {
// Simulate URL change (e.g., browser back button)
locationSubject.next({
pathname: '/test',
search: '?navigation_scope=navScope1',
});
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope1', undefined, undefined);
});
it('should update nav_scope_path when URL changes via back/forward', () => {
// Set current state
dashboardsService.state.navigationScope = 'navScope1';
dashboardsService.state.navScopePath = undefined;
// Simulate URL change with nav_scope_path
locationSubject.next({
pathname: '/test',
search: '?navigation_scope=navScope1&nav_scope_path=' + encodeURIComponent('mimir,loki'),
});
expect(dashboardsService.setNavScopePath).toHaveBeenCalledWith(['mimir', 'loki']);
});
it('should clear navigation scope when removed from URL via back/forward', () => {
// Set current state
dashboardsService.state.navigationScope = 'navScope1';
dashboardsService.state.navScopePath = ['mimir'];
// Simulate URL change (navigation scope removed)
locationSubject.next({
pathname: '/test',
search: '',
});
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined);
});
it('should handle navigation scope change along with nav_scope_path', () => {
// Set current state
dashboardsService.state.navigationScope = 'navScope1';
dashboardsService.state.navScopePath = ['mimir'];
// Simulate URL change to different navigation scope with new path
locationSubject.next({
pathname: '/test',
search: '?navigation_scope=navScope2&nav_scope_path=' + encodeURIComponent('loki,tempo'),
});
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('navScope2', undefined, ['loki', 'tempo']);
});
it('should handle URL-encoded navigation_scope from back/forward', () => {
// Set current state
dashboardsService.state.navigationScope = undefined;
// Simulate URL change with encoded navigation scope
locationSubject.next({
pathname: '/test',
search: '?navigation_scope=' + encodeURIComponent('scope with spaces'),
});
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith('scope with spaces', undefined, undefined);
});
it('should handle nav_scope_path change without navigation_scope', async () => {
// Set current state - no navigation scope but has nav scope path
dashboardsService.state.navigationScope = undefined;
dashboardsService.state.navScopePath = undefined;
selectorService.state.appliedScopes = [{ scopeId: 'scope1' }];
// Simulate URL change with only nav_scope_path
locationSubject.next({
pathname: '/test',
search: '?scopes=scope1&nav_scope_path=mimir',
});
// Wait for changeScopes promise
await Promise.resolve();
expect(dashboardsService.setNavScopePath).toHaveBeenCalledWith(['mimir']);
});
});
});
+51 -6
View File
@@ -5,6 +5,7 @@ import { map, distinctUntilChanged } from 'rxjs/operators';
import { LocationService, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
import { deserializeFolderPath, serializeFolderPath } from './dashboards/scopeNavgiationUtils';
import { ScopesSelectorService } from './selector/ScopesSelectorService';
export interface State {
@@ -72,12 +73,21 @@ export class ScopesService implements ScopesContextValue {
const queryParams = new URLSearchParams(locationService.getLocation().search);
const scopeNodeId = queryParams.get('scope_node');
const navigationScope = queryParams.get('navigation_scope');
const navScopePath = queryParams.get('nav_scope_path');
if (navigationScope) {
this.dashboardsService.setNavigationScope(navigationScope);
this.dashboardsService.setNavigationScope(
navigationScope,
undefined,
navScopePath ? deserializeFolderPath(navScopePath) : undefined
);
}
this.changeScopes(queryParams.getAll('scopes'), undefined, scopeNodeId ?? undefined);
this.changeScopes(queryParams.getAll('scopes'), undefined, scopeNodeId ?? undefined).then(() => {
if (navScopePath && !navigationScope) {
this.dashboardsService.setNavScopePath(deserializeFolderPath(navScopePath));
}
});
// Pre-load scope node (which loads parent too)
const nodeToPreload = scopeNodeId;
@@ -99,6 +109,9 @@ export class ScopesService implements ScopesContextValue {
const scopes = queryParams.getAll('scopes');
const scopeNodeId = queryParams.get('scope_node');
const navigationScope = queryParams.get('navigation_scope');
const navScopePath = queryParams.get('nav_scope_path');
// Check if new scopes are different from the old scopes
const currentScopes = this.selectorService.state.appliedScopes.map((scope) => scope.scopeId);
if (scopes.length && !isEqual(scopes, currentScopes)) {
@@ -107,6 +120,31 @@ export class ScopesService implements ScopesContextValue {
// changes the URL directly, it would trigger a reload so scopes would still be reset.
this.changeScopes(scopes, undefined, scopeNodeId ?? undefined);
}
// Handle navigation_scope and nav_scope_path changes from back/forward navigation
const currentNavigationScope = this.dashboardsService.state.navigationScope;
const currentNavScopePath = this.dashboardsService.state.navScopePath;
const newNavScopePath = navScopePath ? deserializeFolderPath(navScopePath) : undefined;
const decodedNavigationScope = navigationScope ? decodeURIComponent(navigationScope) : undefined;
const navigationScopeChanged = decodedNavigationScope !== currentNavigationScope;
const navScopePathChanged = !isEqual(newNavScopePath, currentNavScopePath);
if (navigationScopeChanged) {
// Navigation scope changed - do full update
if (decodedNavigationScope) {
this.dashboardsService.setNavigationScope(decodedNavigationScope, undefined, newNavScopePath);
} else if (newNavScopePath?.length) {
this.changeScopes(scopes, undefined, scopeNodeId ?? undefined).then(() => {
this.dashboardsService.setNavScopePath(newNavScopePath);
});
} else {
this.dashboardsService.setNavigationScope(undefined);
}
} else if (navScopePathChanged) {
// Navigation scope unchanged but path changed
this.dashboardsService.setNavScopePath(newNavScopePath);
}
})
);
@@ -137,10 +175,17 @@ export class ScopesService implements ScopesContextValue {
// Update the URL based on change in the navigation scope
this.subscriptions.push(
this.dashboardsService.subscribeToState((state, prevState) => {
if (state.navigationScope !== prevState.navigationScope) {
this.locationService.partial({
navigation_scope: state.navigationScope,
});
if (
state.navigationScope !== prevState.navigationScope ||
!isEqual(state.navScopePath, prevState.navScopePath)
) {
this.locationService.partial(
{
navigation_scope: state.navigationScope ? encodeURIComponent(state.navigationScope) : null,
nav_scope_path: state.navScopePath?.length ? serializeFolderPath(state.navScopePath) : null,
},
true
);
}
})
);
@@ -67,7 +67,12 @@ export function ScopesDashboards() {
/>
) : filteredFolders[''] ? (
<ScrollContainer>
<ScopesDashboardsTree folders={filteredFolders} folderPath={['']} onFolderUpdate={updateFolder} />
<ScopesDashboardsTree
folders={filteredFolders}
folderPath={['']}
subScopePath={[]}
onFolderUpdate={updateFolder}
/>
</ScrollContainer>
) : (
<p className={styles.noResultsContainer} data-testid="scopes-dashboards-notFoundForFilter">
@@ -839,4 +839,52 @@ describe('ScopesDashboardsService', () => {
expect(service.state.drawerOpened).toBe(true);
});
});
describe('setNavScopePath', () => {
beforeEach(() => {
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
});
it('should set nav scope path', async () => {
await service.setNavScopePath(['mimir']);
expect(service.state.navScopePath).toEqual(['mimir']);
});
it('should replace existing path with new path', async () => {
await service.setNavScopePath(['mimir']);
expect(service.state.navScopePath).toEqual(['mimir']);
await service.setNavScopePath(['loki']);
expect(service.state.navScopePath).toEqual(['loki']);
});
it('should handle multiple scopes in path', async () => {
await service.setNavScopePath(['mimir', 'loki']);
expect(service.state.navScopePath).toEqual(['mimir', 'loki']);
});
it('should clear path with empty array', async () => {
await service.setNavScopePath(['mimir', 'loki']);
expect(service.state.navScopePath).toEqual(['mimir', 'loki']);
await service.setNavScopePath([]);
expect(service.state.navScopePath).toEqual([]);
});
it('should handle undefined path as empty array', async () => {
await service.setNavScopePath(['mimir']);
expect(service.state.navScopePath).toEqual(['mimir']);
await service.setNavScopePath(undefined);
expect(service.state.navScopePath).toEqual([]);
});
it('should not update state if path is unchanged', async () => {
await service.setNavScopePath(['mimir']);
await service.setNavScopePath(['mimir']);
// Path should remain the same
expect(service.state.navScopePath).toEqual(['mimir']);
});
});
});
@@ -7,8 +7,13 @@ import { config, locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
import { isCurrentPath } from './scopeNavgiationUtils';
import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types';
import { buildSubScopePath, isCurrentPath } from './scopeNavgiationUtils';
import {
ScopeNavigation,
SuggestedNavigationsFolder,
SuggestedNavigationsFoldersMap,
SuggestedNavigationsMap,
} from './types';
interface ScopesDashboardsServiceState {
// State of the drawer showing related dashboards
@@ -24,6 +29,8 @@ interface ScopesDashboardsServiceState {
loading: boolean;
searchQuery: string;
navigationScope?: string;
// Path of subScopes which should be expanded
navScopePath?: string[];
}
export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsServiceState> {
@@ -38,6 +45,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
forScopeNames: [],
loading: false,
searchQuery: '',
navScopePath: undefined,
});
// Add/ remove location subscribtion based on the drawer opened state
@@ -57,9 +65,40 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
});
}
private openSubScopeFolder = (subScopePath: string[]) => {
const subScope = subScopePath[subScopePath.length - 1];
const path = buildSubScopePath(subScope, this.state.folders);
// Get path to the folder - path can now be undefined
if (path && path.length > 0) {
this.updateFolder(path, true);
}
};
public setNavScopePath = async (navScopePath?: string[]) => {
const navScopePathArray = navScopePath ?? [];
if (!isEqual(navScopePathArray, this.state.navScopePath)) {
this.updateState({ navScopePath: navScopePathArray });
for (const subScope of navScopePathArray) {
// Find the actual path to the folder with this subScopeName
const folderPath = buildSubScopePath(subScope, this.state.folders);
if (folderPath && folderPath.length > 0) {
await this.fetchSubScopeItems(folderPath, subScope);
this.openSubScopeFolder([subScope]);
}
}
}
};
// The fallbackScopeNames is used to fetch the ScopeNavigations for the current dashboard when the navigationScope is not set.
// You only need to awaut this function if you need to wait for the dashboards to be fetched before doing something else.
public setNavigationScope = async (navigationScope?: string, fallbackScopeNames?: string[]) => {
public setNavigationScope = async (
navigationScope?: string,
fallbackScopeNames?: string[],
navScopePath?: string[]
) => {
if (this.state.navigationScope === navigationScope) {
return;
}
@@ -67,6 +106,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
const forScopeNames = navigationScope ? [navigationScope] : (fallbackScopeNames ?? []);
this.updateState({ navigationScope, drawerOpened: forScopeNames.length > 0 });
await this.fetchDashboards(forScopeNames);
await this.setNavScopePath(navScopePath);
};
// Expand the group that matches the current path, if it is not already expanded
@@ -148,6 +188,15 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
};
private fetchSubScopeItems = async (path: string[], subScopeName: string) => {
// Check if folder already has content - skip fetching to preserve existing state
const targetFolder = this.getFolder(path);
if (
targetFolder &&
(Object.keys(targetFolder.folders).length > 0 || Object.keys(targetFolder.suggestedNavigations).length > 0)
) {
return;
}
let subScopeFolders: SuggestedNavigationsFoldersMap | undefined;
try {
@@ -208,6 +257,15 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
this.updateState({ folders, filteredFolders });
};
// Helper to get a folder at a given path
private getFolder = (path: string[]): SuggestedNavigationsFolder | undefined => {
let folder: SuggestedNavigationsFoldersMap = this.state.folders;
for (let i = 0; i < path.length - 1; i++) {
folder = folder[path[i]]?.folders ?? {};
}
return folder[path[path.length - 1]];
};
public changeSearchQuery = (searchQuery: string) => {
searchQuery = searchQuery ?? '';
@@ -12,10 +12,17 @@ export interface ScopesDashboardsTreeProps {
subScope?: string;
folders: SuggestedNavigationsFoldersMap;
folderPath: string[];
subScopePath?: string[];
onFolderUpdate: OnFolderUpdate;
}
export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) {
export function ScopesDashboardsTree({
subScopePath,
subScope,
folders,
folderPath,
onFolderUpdate,
}: ScopesDashboardsTreeProps) {
const [queryParams] = useQueryParams();
const styles = useStyles2(getStyles);
@@ -54,6 +61,7 @@ export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUp
{regularNavigations.map((navigation) => (
<ScopesNavigationTreeLink
subScope={subScope}
subScopePath={subScopePath}
key={navigation.id + navigation.title}
to={urlUtil.renderUrl(navigation.url, queryParams)}
title={navigation.title}
@@ -68,6 +76,7 @@ export function ScopesDashboardsTree({ subScope, folders, folderPath, onFolderUp
{subScopeFolders.map(([subFolderId, subFolder]) => (
<ScopesDashboardsTreeFolderItem
key={subFolderId}
subScopePath={[...(subScopePath ?? []), subFolder.subScopeName ?? '']}
folder={subFolder}
folders={folder.folders}
folderPath={[...folderPath, subFolderId]}
@@ -16,6 +16,9 @@ const mockScopesSelectorService = {
const mockScopesDashboardsService = {
setNavigationScope: jest.fn(),
state: {
navScopePath: undefined,
},
};
jest.mock('../ScopesContextProvider', () => ({
@@ -133,7 +136,7 @@ describe('ScopesDashboardsTreeFolderItem', () => {
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
await user.click(exchangeButton);
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, ['subScope1']);
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, []);
});
it('calls changeScopes when exchange icon is clicked', async () => {
@@ -152,7 +155,7 @@ describe('ScopesDashboardsTreeFolderItem', () => {
const exchangeButton = screen.getByRole('button', { name: /change root scope/i });
await user.click(exchangeButton);
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1']);
expect(mockScopesSelectorService.changeScopes).toHaveBeenCalledWith(['subScope1'], undefined, undefined, false);
});
it('passes subScope prop to ScopesDashboardsTree when folder is expanded', () => {
@@ -14,9 +14,11 @@ export interface ScopesDashboardsTreeFolderItemProps {
folderPath: string[];
folders: SuggestedNavigationsFoldersMap;
onFolderUpdate: OnFolderUpdate;
subScopePath?: string[];
}
export function ScopesDashboardsTreeFolderItem({
subScopePath,
folder,
folderPath,
folders,
@@ -53,12 +55,27 @@ export function ScopesDashboardsTreeFolderItem({
scope: folder.subScopeName || '',
})}
name="exchange-alt"
onClick={(e) => {
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (folder.subScopeName && scopesSelectorService) {
scopesDashboardsService?.setNavigationScope(undefined, [folder.subScopeName]);
scopesSelectorService.changeScopes([folder.subScopeName]);
const activeSubScopePath = scopesDashboardsService?.state.navScopePath;
// Check if the active scope is a child of the current folder's scope
const activeScope = activeSubScopePath?.[activeSubScopePath.length - 1];
const folderLocationInActivePath = activeSubScopePath?.indexOf(folder.subScopeName) ?? -1;
await scopesDashboardsService?.setNavigationScope(
folderLocationInActivePath >= 0 ? folder.subScopeName : undefined,
undefined,
activeSubScopePath?.slice(folderLocationInActivePath + 1) ?? []
);
// Now changeScopes will skip fetchDashboards because navigationScope is set
scopesSelectorService.changeScopes(
folderLocationInActivePath >= 0 && activeScope ? [activeScope] : [folder.subScopeName],
undefined,
undefined,
false
);
}
}}
/>
@@ -68,6 +85,7 @@ export function ScopesDashboardsTreeFolderItem({
{folder.expanded && (
<div className={styles.children}>
<ScopesDashboardsTree
subScopePath={subScopePath}
subScope={folder.subScopeName}
folders={folders}
folderPath={folderPath}
@@ -209,7 +209,7 @@ describe('ScopesNavigationTreeLink', () => {
const link = screen.getByTestId('scopes-dashboards-test-id');
await userEvent.click(link);
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith('currentScope');
expect(mockScopesDashboardsService.setNavigationScope).toHaveBeenCalledWith('currentScope', undefined, undefined);
});
it('should not set navigation scope when already set', async () => {
@@ -8,16 +8,17 @@ import { Icon, useStyles2 } from '@grafana/ui';
import { useScopesServices } from '../ScopesContextProvider';
import { isCurrentPath, normalizePath } from './scopeNavgiationUtils';
import { isCurrentPath, normalizePath, serializeFolderPath } from './scopeNavgiationUtils';
export interface ScopesNavigationTreeLinkProps {
subScope?: string;
to: string;
title: string;
id: string;
subScopePath?: string[];
}
export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavigationTreeLinkProps) {
export function ScopesNavigationTreeLink({ subScope, to, title, id, subScopePath }: ScopesNavigationTreeLinkProps) {
const styles = useStyles2(getStyles);
const linkIcon = useMemo(() => getLinkIcon(to), [to]);
const locPathname = useLocation().pathname;
@@ -25,7 +26,7 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
// Ignore query params
const isCurrent = isCurrentPath(locPathname, to);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
if (subScope) {
e.preventDefault(); // Prevent default Link navigation
@@ -39,11 +40,18 @@ export function ScopesNavigationTreeLink({ subScope, to, title, id }: ScopesNavi
const searchParams = new URLSearchParams(url.search);
if (!currentNavigationScope && currentScope) {
searchParams.set('navigation_scope', currentScope);
services?.scopesDashboardsService?.setNavigationScope(currentScope);
await services?.scopesDashboardsService?.setNavigationScope(
currentScope,
undefined,
subScopePath && subScopePath.length > 0 ? subScopePath : undefined
);
}
// Update query params with the new subScope
searchParams.set('scopes', subScope);
// Set nav_scope_path to the subScopePath
searchParams.set('nav_scope_path', subScopePath ? serializeFolderPath(subScopePath) : '');
// Remove scope_node and scope_parent since we're changing to a subScope
searchParams.delete('scope_node');
searchParams.delete('scope_parent');
@@ -1,4 +1,11 @@
import { getDashboardPathForComparison, isCurrentPath } from './scopeNavgiationUtils';
import {
buildSubScopePath,
deserializeFolderPath,
getDashboardPathForComparison,
isCurrentPath,
serializeFolderPath,
} from './scopeNavgiationUtils';
import { SuggestedNavigationsFoldersMap } from './types';
describe('scopeNavgiationUtils', () => {
it('should return the correct path for a dashboard', () => {
@@ -28,4 +35,194 @@ describe('scopeNavgiationUtils', () => {
expect(isCurrentPath('/d/dashboardId/slug', '/d/dashboardId#hash')).toBe(true);
expect(isCurrentPath('/d/dashboardId', '/d/dashboardId#hash')).toBe(true);
});
describe('deserializeFolderPath', () => {
it('should return empty array for empty string', () => {
expect(deserializeFolderPath('')).toEqual([]);
});
it('should parse a simple comma-separated string', () => {
expect(deserializeFolderPath('mimir,loki')).toEqual(['mimir', 'loki']);
});
it('should handle single value', () => {
expect(deserializeFolderPath('mimir')).toEqual(['mimir']);
});
it('should trim whitespace around values', () => {
expect(deserializeFolderPath(' mimir , loki ')).toEqual(['mimir', 'loki']);
});
it('should handle URL-encoded strings', () => {
expect(deserializeFolderPath(encodeURIComponent('mimir,loki'))).toEqual(['mimir', 'loki']);
});
it('should handle URL-encoded strings with special characters', () => {
expect(deserializeFolderPath(encodeURIComponent('folder one,folder two'))).toEqual(['folder one', 'folder two']);
});
it('should fallback to split without decoding if decodeURIComponent fails', () => {
// Invalid URI sequence that would cause decodeURIComponent to throw
const invalidUri = '%E0%A4%A';
expect(deserializeFolderPath(invalidUri)).toEqual(['%E0%A4%A']);
});
});
describe('serializeFolderPath', () => {
it('should return empty string for empty array', () => {
expect(serializeFolderPath([])).toBe('');
});
it('should serialize a simple array', () => {
expect(serializeFolderPath(['mimir', 'loki'])).toBe(encodeURIComponent('mimir,loki'));
});
it('should handle single value', () => {
expect(serializeFolderPath(['mimir'])).toBe('mimir');
});
it('should handle values with spaces', () => {
expect(serializeFolderPath(['folder one', 'folder two'])).toBe(encodeURIComponent('folder one,folder two'));
});
it('should return empty string for null/undefined input', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(serializeFolderPath(null as any)).toBe('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(serializeFolderPath(undefined as any)).toBe('');
});
});
describe('serializeFolderPath and deserializeFolderPath round-trip', () => {
it('should round-trip simple paths', () => {
const original = ['mimir', 'loki'];
const serialized = serializeFolderPath(original);
const deserialized = deserializeFolderPath(serialized);
expect(deserialized).toEqual(original);
});
it('should round-trip paths with spaces', () => {
const original = ['folder one', 'folder two'];
const serialized = serializeFolderPath(original);
const deserialized = deserializeFolderPath(serialized);
expect(deserialized).toEqual(original);
});
});
describe('buildSubScopePath', () => {
it('should return undefined when folders is empty', () => {
const folders: SuggestedNavigationsFoldersMap = {};
expect(buildSubScopePath('mimir', folders)).toBeUndefined();
});
it('should find subScope at root level', () => {
const folders: SuggestedNavigationsFoldersMap = {
'Mimir Dashboards': {
title: 'Mimir Dashboards',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'mimir',
},
};
expect(buildSubScopePath('mimir', folders)).toEqual(['Mimir Dashboards']);
});
it('should find subScope in nested folders', () => {
const folders: SuggestedNavigationsFoldersMap = {
'': {
title: '',
expanded: true,
folders: {
'Parent Folder': {
title: 'Parent Folder',
expanded: false,
folders: {
'Mimir Dashboards': {
title: 'Mimir Dashboards',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'mimir',
},
},
suggestedNavigations: {},
},
},
suggestedNavigations: {},
},
};
expect(buildSubScopePath('mimir', folders)).toEqual(['', 'Parent Folder', 'Mimir Dashboards']);
});
it('should return undefined when subScope is not found', () => {
const folders: SuggestedNavigationsFoldersMap = {
'': {
title: '',
expanded: true,
folders: {
'Loki Dashboards': {
title: 'Loki Dashboards',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'loki',
},
},
suggestedNavigations: {},
},
};
expect(buildSubScopePath('mimir', folders)).toBeUndefined();
});
it('should return first match when multiple folders have the same subScope', () => {
const folders: SuggestedNavigationsFoldersMap = {
'Mimir Dashboards': {
title: 'Mimir Dashboards',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'mimir',
},
'Mimir Overview': {
title: 'Mimir Overview',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'mimir',
},
};
// Should return the first one found (order depends on Object.entries)
const result = buildSubScopePath('mimir', folders);
expect(result).toBeDefined();
expect(result?.length).toBe(1);
});
it('should find deeply nested subScope', () => {
const folders: SuggestedNavigationsFoldersMap = {
level1: {
title: 'Level 1',
expanded: true,
folders: {
level2: {
title: 'Level 2',
expanded: true,
folders: {
level3: {
title: 'Level 3',
expanded: false,
folders: {},
suggestedNavigations: {},
subScopeName: 'deep-scope',
},
},
suggestedNavigations: {},
},
},
suggestedNavigations: {},
},
};
expect(buildSubScopePath('deep-scope', folders)).toEqual(['level1', 'level2', 'level3']);
});
});
});
@@ -1,3 +1,5 @@
import { SuggestedNavigationsFoldersMap } from './types';
// Helper function to get the base path for a dashboard URL for comparison purposes.
// e.g., /d/dashboardId/slug -> /d/dashboardId
// /d/dashboardId -> /d/dashboardId
@@ -5,12 +7,63 @@ export function getDashboardPathForComparison(pathname: string): string {
return pathname.split('/').slice(0, 3).join('/');
}
/**
* Finds the path to a folder with the given subScopeName by searching recursively.
* @param subScope - The subScope name to find
* @param folders - The root folder structure to search
* @returns Array representing the path to the folder, or undefined if not found
*/
export function buildSubScopePath(subScope: string, folders: SuggestedNavigationsFoldersMap): string[] | undefined {
function findPath(currentFolders: SuggestedNavigationsFoldersMap, currentPath: string[]): string[] | undefined {
for (const [key, folder] of Object.entries(currentFolders)) {
const newPath = [...currentPath, key];
if (folder.subScopeName === subScope) {
return newPath;
}
// Search in nested folders
const nestedPath = findPath(folder.folders, newPath);
if (nestedPath) {
return nestedPath;
}
}
return undefined;
}
return findPath(folders, []);
}
export function normalizePath(path: string): string {
// Remove query + hash + trailing slash (except root)
const noQuery = path.split('?')[0].split('#')[0];
return noQuery !== '/' && noQuery.endsWith('/') ? noQuery.slice(0, -1) : noQuery;
}
/**
* Deserializes a comma-separated folder path string into an array.
* Handles URL-encoded strings.
*/
export function deserializeFolderPath(navScopePath: string): string[] {
if (!navScopePath) {
return [];
}
try {
const decoded = decodeURIComponent(navScopePath);
return decoded.split(',').map((s) => s.trim());
} catch {
return navScopePath.split(',').map((s) => s.trim());
}
}
/**
* Serializes a folder path array into a comma-separated string.
*/
export function serializeFolderPath(path: string[]): string {
if (!path) {
return '';
}
return encodeURIComponent(path.join(','));
}
// Pathname comes from location.pathname
export function isCurrentPath(pathname: string, to: string): boolean {
const isDashboard = to.startsWith('/d/');
@@ -444,7 +444,7 @@ describe('ScopesSelectorService', () => {
await service.selectScope('test-scope-node');
await service.apply();
await service.removeAllScopes();
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined);
expect(dashboardsService.setNavigationScope).toHaveBeenCalledWith(undefined, undefined, undefined);
});
});
@@ -372,13 +372,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Redirect to the scope node's redirect URL if it exists, otherwise redirect to the first scope navigation.
private redirectAfterApply = (scopeNode: ScopeNode | undefined) => {
// Check if the selected scope has a redirect path
if (scopeNode && scopeNode.spec.redirectPath && typeof scopeNode.spec.redirectPath === 'string') {
locationService.push(scopeNode.spec.redirectPath);
return;
}
// Redirect to first scopeNavigation if current URL isn't a scopeNavigation
// Check if we are currently on an active scope navigation
const currentPath = locationService.getLocation().pathname;
const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => {
if (!('url' in s.spec) || typeof s.spec.url !== 'string') {
@@ -387,6 +381,20 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
return isCurrentPath(currentPath, s.spec.url);
});
// Only redirect to redirectPath if we are not currently on an active scope navigation
if (
!activeScopeNavigation &&
scopeNode &&
scopeNode.spec.redirectPath &&
typeof scopeNode.spec.redirectPath === 'string' &&
// Don't redirect if we're already on the target path
!isCurrentPath(currentPath, scopeNode.spec.redirectPath)
) {
locationService.push(scopeNode.spec.redirectPath);
return;
}
// Redirect to first scopeNavigation if current URL isn't a scopeNavigation
if (!activeScopeNavigation && this.dashboardsService.state.scopeNavigations.length > 0) {
// Redirect to the first available scopeNavigation
const firstScopeNavigation = this.dashboardsService.state.scopeNavigations[0];
@@ -396,7 +404,9 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
'url' in firstScopeNavigation.spec &&
typeof firstScopeNavigation.spec.url === 'string' &&
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
firstScopeNavigation.spec.url.includes('/d/')
firstScopeNavigation.spec.url.includes('/d/') &&
// Don't redirect if we're already on the target path
!isCurrentPath(currentPath, firstScopeNavigation.spec.url)
) {
locationService.push(firstScopeNavigation.spec.url);
}
@@ -405,7 +415,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
public removeAllScopes = () => {
this.applyScopes([], false);
this.dashboardsService.setNavigationScope(undefined);
this.dashboardsService.setNavigationScope(undefined, undefined, undefined);
};
private addRecentScopes = (scopes: Scope[], parentNode?: ScopeNode, scopeNodeId?: string) => {
+11 -3
View File
@@ -8897,6 +8897,13 @@
"aria-label-default": "Vyberte barvu",
"aria-label-selected-color": "{{colorLabel}} barva"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Odstranit",
"cancel": "Zrušit",
@@ -9094,6 +9101,8 @@
"interactive-table": {
"aria-label-collapse-all": "Sbalit všechny řádky",
"aria-label-expand-all": "Rozbalit všechny řádky",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Řádek přepnutí rozbalen",
"tooltip-collapse-all": "Sbalit všechny řádky",
"tooltip-expand-all": "Rozbalit všechny řádky"
@@ -12529,13 +12538,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Wählen Sie eine Farbe",
"aria-label-selected-color": "{{colorLabel}} Farbe"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Löschen",
"cancel": "Abbrechen",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Alle Zeilen einklappen",
"aria-label-expand-all": "Alle Zeilen erweitern",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Zeile umschalten erweitert",
"tooltip-collapse-all": "Alle Zeilen einklappen",
"tooltip-expand-all": "Alle Zeilen erweitern"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+1 -1
View File
@@ -11838,7 +11838,7 @@
"modal-title-set-up-public-access": "Set up public access",
"modal-title-set-up-required-features": "Set up required features",
"step-description-copy-url": "From the ngrok output, copy the https:// forwarding URL that looks like this:",
"step-description-enable-feature-toggles": "Add these settings to your custom.ini file to enable necessary features:",
"step-description-enable-feature-toggles": "Add the provisioning feature toggle to your custom.ini file. Note: kubernetesDashboards is enabled by default, but if you have explicitly disabled it, you will need to enable it in your Grafana settings or remove the override from your configuration.",
"step-description-start-ngrok": "Run this command to create a secure tunnel to your local Grafana:",
"step-description-update-grafana-config": "Add this to your custom.ini file, replacing the URL with your actual ngrok URL:",
"step-title-copy-url": "Copy your public URL",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Elegir un color",
"aria-label-selected-color": "{{colorLabel}} color"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Eliminar",
"cancel": "Cancelar",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Contraer todas las filas",
"aria-label-expand-all": "Expandir todas las filas",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Alternar fila expandida",
"tooltip-collapse-all": "Contraer todas las filas",
"tooltip-expand-all": "Expandir todas las filas"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Choisir une couleur",
"aria-label-selected-color": "Couleur {{colorLabel}}"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Supprimer",
"cancel": "Annuler",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Réduire toutes les lignes",
"aria-label-expand-all": "Développer toutes les lignes",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Basculer la ligne développée",
"tooltip-collapse-all": "Réduire toutes les lignes",
"tooltip-expand-all": "Développer toutes les lignes"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Válasszon színt",
"aria-label-selected-color": "{{colorLabel}} szín"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Törlés",
"cancel": "Mégse",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Minden sor összecsukása",
"aria-label-expand-all": "Minden sor kibontása",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Sorkibontás váltása",
"tooltip-collapse-all": "Minden sor összecsukása",
"tooltip-expand-all": "Minden sor kibontása"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8792,6 +8792,13 @@
"aria-label-default": "Pilih warna",
"aria-label-selected-color": "warna {{colorLabel}}"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Hapus",
"cancel": "Batalkan",
@@ -8989,6 +8996,8 @@
"interactive-table": {
"aria-label-collapse-all": "Ciutkan semua baris",
"aria-label-expand-all": "Perluas semua baris",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Alihkan baris diperluas",
"tooltip-collapse-all": "Ciutkan semua baris",
"tooltip-expand-all": "Perluas semua baris"
@@ -12373,13 +12382,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Scegli un colore",
"aria-label-selected-color": "Colore {{colorLabel}}"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Elimina",
"cancel": "Annulla",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Ridurre tutte le righe",
"aria-label-expand-all": "Espandi tutte le righe",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Attiva/disattiva riga espansa",
"tooltip-collapse-all": "Ridurre tutte le righe",
"tooltip-expand-all": "Espandi tutte le righe"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8792,6 +8792,13 @@
"aria-label-default": "色を選択",
"aria-label-selected-color": "{{colorLabel}}色"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "削除",
"cancel": "キャンセル",
@@ -8989,6 +8996,8 @@
"interactive-table": {
"aria-label-collapse-all": "すべての行を折りたたむ",
"aria-label-expand-all": "すべての行を広げる",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "行の展開を切り替える",
"tooltip-collapse-all": "すべての行を折りたたむ",
"tooltip-expand-all": "すべての行を広げる"
@@ -12373,13 +12382,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8792,6 +8792,13 @@
"aria-label-default": "색상 선택",
"aria-label-selected-color": "{{colorLabel}} 색상"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "삭제",
"cancel": "취소",
@@ -8989,6 +8996,8 @@
"interactive-table": {
"aria-label-collapse-all": "모든 행 접기",
"aria-label-expand-all": "모든 행 펼치기",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "토글하여 펼쳐진 행으로 전환",
"tooltip-collapse-all": "모든 행 접기",
"tooltip-expand-all": "모든 행 펼치기"
@@ -12373,13 +12382,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Kies een kleur",
"aria-label-selected-color": "Kleur {{colorLabel}}"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Verwijderen",
"cancel": "Annuleren",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Alle rijen samenvouwen",
"aria-label-expand-all": "Alle rijen uitvouwen",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Rij in-/uitschakelen uitgevouwen",
"tooltip-collapse-all": "Alle rijen samenvouwen",
"tooltip-expand-all": "Alle rijen uitvouwen"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8897,6 +8897,13 @@
"aria-label-default": "Wybierz kolor",
"aria-label-selected-color": "Kolor {{colorLabel}}"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Usuń",
"cancel": "Anuluj",
@@ -9094,6 +9101,8 @@
"interactive-table": {
"aria-label-collapse-all": "Zwiń wszystkie wiersze",
"aria-label-expand-all": "Rozwiń wszystkie wiersze",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Przełącz rozwinięcie wiersza",
"tooltip-collapse-all": "Zwiń wszystkie wiersze",
"tooltip-expand-all": "Rozwiń wszystkie wiersze"
@@ -12529,13 +12538,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Escolha uma cor",
"aria-label-selected-color": "cor {{colorLabel}}"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Excluir",
"cancel": "Cancelar",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Recolher todas as linhas",
"aria-label-expand-all": "Expandir todas as linhas",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Alternar linha expandida",
"tooltip-collapse-all": "Recolher todas as linhas",
"tooltip-expand-all": "Expandir todas as linhas"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Escolher uma cor",
"aria-label-selected-color": "cor {{colorLabel}} "
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Eliminar",
"cancel": "Cancelar",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Recolher todas as linhas",
"aria-label-expand-all": "Expandir todas as linhas",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Alternar linha expandida",
"tooltip-collapse-all": "Recolher todas as linhas",
"tooltip-expand-all": "Expandir todas as linhas"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8897,6 +8897,13 @@
"aria-label-default": "Выбрать цвет",
"aria-label-selected-color": "{{colorLabel}} цвет"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Удалить",
"cancel": "Отмена",
@@ -9094,6 +9101,8 @@
"interactive-table": {
"aria-label-collapse-all": "Свернуть все строки",
"aria-label-expand-all": "Развернуть все строки",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Развернута строка переключения",
"tooltip-collapse-all": "Свернуть все строки",
"tooltip-expand-all": "Развернуть все строки"
@@ -12529,13 +12538,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Välj en färg",
"aria-label-selected-color": "{{colorLabel}} färg"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Radera",
"cancel": "Avbryt",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Dölj alla rader",
"aria-label-expand-all": "Expandera alla rader",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Växla rad utökad",
"tooltip-collapse-all": "Dölj alla rader",
"tooltip-expand-all": "Expandera alla rader"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8827,6 +8827,13 @@
"aria-label-default": "Bir renk seçin",
"aria-label-selected-color": "{{colorLabel}} rengi"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "Sil",
"cancel": "İptal",
@@ -9024,6 +9031,8 @@
"interactive-table": {
"aria-label-collapse-all": "Tüm satırları daralt",
"aria-label-expand-all": "Tüm satırları genişlet",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "Satır genişletmeyi aç/kapat",
"tooltip-collapse-all": "Tüm satırları daralt",
"tooltip-expand-all": "Tüm satırları genişlet"
@@ -12425,13 +12434,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8792,6 +8792,13 @@
"aria-label-default": "选择颜色",
"aria-label-selected-color": "{{colorLabel}} 颜色"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "删除",
"cancel": "取消",
@@ -8989,6 +8996,8 @@
"interactive-table": {
"aria-label-collapse-all": "折叠所有行",
"aria-label-expand-all": "展开所有行",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "切换行展开",
"tooltip-collapse-all": "折叠所有行",
"tooltip-expand-all": "展开所有行"
@@ -12373,13 +12382,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",
+11 -3
View File
@@ -8792,6 +8792,13 @@
"aria-label-default": "挑選顏色",
"aria-label-selected-color": "{{colorLabel}} 顏色"
},
"components": {
"sparkline": {
"warning": {
"too-few-values": ""
}
}
},
"confirm-button": {
"aria-label-delete": "刪除",
"cancel": "取消",
@@ -8989,6 +8996,8 @@
"interactive-table": {
"aria-label-collapse-all": "收闔所有列",
"aria-label-expand-all": "展開所有列",
"aria-label-sort-column": "",
"expand-row-header": "",
"expand-row-tooltip": "切換列展開",
"tooltip-collapse-all": "收闔所有列",
"tooltip-expand-all": "展開所有列"
@@ -12373,13 +12382,12 @@
"effects": {
"bar-glow": "",
"center-glow": "",
"gradient": "",
"label": "",
"rounded-bars": "",
"spotlight": "",
"spotlight-tooltip": ""
},
"gradient": "",
"gradient-auto": "",
"gradient-none": "",
"segment-count": "",
"segment-spacing": "",
"shape": "",