Merge branch 'main' into wb/pluginmeta-local
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user