From 3a54c3abae438e7463fdcbf292f43289de55c378 Mon Sep 17 00:00:00 2001 From: Bruno Abrantes Date: Mon, 28 Jul 2025 15:50:26 +0200 Subject: [PATCH 01/39] chore: adds documentation around the dual writer (#108687) * chore: adds documentation around the dual writer Signed-off-by: Bruno Abrantes * fix: innacuracies in error returned, disambiguate (validation) and move table upwards for more clarity Signed-off-by: Bruno Abrantes --------- Signed-off-by: Bruno Abrantes --- pkg/storage/unified/README.md | 312 ++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index 84efac5df86..11344724271 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -469,3 +469,315 @@ For debugging purposes, you can view the memberlist status by visitting `http:// that every instance you create is part of the memberlist. You can also visit `http://127.0.0.1:3000/ring` to view the ring status and the storage-api servers that are part of the ring. + +--- + +## Dual Writer System + +The Dual Writer system is a critical component of Unified Storage that manages the transition between legacy storage and unified storage during the migration process. It provides six different modes (0-5) that control how data is read from and written to both storage systems. + +### Dual Writer Mode Reference Table + +| Mode | Description | Read Source | Read Behavior | Write Targets | Write Behavior | Error Handling | Background Sync | +|------|-------------|-------------|---------------|---------------|----------------|----------------|-----------------| +| **0** | Disabled | Legacy Only | Synchronous | Legacy Only | Synchronous | Legacy errors bubble up | None | +| **1** | Legacy Primary + Best Effort Unified | Legacy Only | Legacy: Sync
Unified: Async (background) | Legacy + Unified | Legacy: Sync
Unified: Async (background) | Only legacy errors bubble up.
Unified errors logged but ignored | Active - syncs legacy → unified | +| **2** | Legacy Primary + Unified Sync | Legacy Only | Legacy: Sync
Unified: Sync (verification read) | Legacy + Unified | Legacy: Sync
Unified: Sync | Legacy errors bubble up first.
Unified errors bubble up (except NotFound which is ignored).
If write succeeds in legacy but fails in unified, unified error bubbles up and legacy is cleaned up | Active - syncs legacy → unified | +| **3** | Unified Primary + Legacy Sync | Unified Primary | Unified: Sync
Legacy: Fallback on NotFound | Legacy + Unified | Legacy: Sync
Unified: Sync | Legacy errors bubble up first.
If legacy succeeds but unified fails, unified error bubbles up and legacy is cleaned up | Prerequisite - only available after sync completes | +| **4** | Unified Only (Post-Sync) | Unified Only | Synchronous | Unified Only | Synchronous | Unified errors bubble up | Prerequisite - only available after sync completes | +| **5** | Unified Only (Force) | Unified Only | Synchronous | Unified Only | Synchronous | Unified errors bubble up | None - bypasses sync requirements | + + +### Dual Writer Architecture + +The dual writer acts as an intermediary layer that sits between the API layer and the storage backends, routing read and write operations based on the configured mode. + +```mermaid +graph TB + subgraph "API Layer" + A[REST API Request] + end + + subgraph "Dual Writer Layer" + B[Dual Writer] + B --> C{Mode Decision} + end + + subgraph "Storage Backends" + D[Legacy Storage
SQL Database] + E[Unified Storage
K8s-style Storage] + end + + subgraph "Background Services" + F[Data Syncer
Background Job] + G[Server Lock Service
Distributed Lock] + end + + A --> B + C --> D + C --> E + F --> D + F --> E + F --> G +``` + +### Mode-Specific Data Flow Diagrams + +#### Mode 0: Legacy Only (Disabled) +```mermaid +sequenceDiagram + participant API as API Request + participant DW as Dual Writer + participant LS as Legacy Storage + participant US as Unified Storage + + Note over DW: Mode 0 - Unified Storage Disabled + + API->>DW: Read/Write Request + DW->>LS: Forward Request + LS-->>DW: Response + DW-->>API: Response + + Note over US: Not Used +``` + +#### Mode 1: Legacy Primary + Best Effort Unified +```mermaid +sequenceDiagram + participant API as API Request + participant DW as Dual Writer + participant LS as Legacy Storage + participant US as Unified Storage + participant BG as Background Sync + + Note over DW: Mode 1 - Legacy Primary, Unified Best-Effort + + %% Read Operations + API->>DW: Read Request + DW->>LS: Read from Legacy + LS-->>DW: Data + DW->>US: Read from Unified (Background) + Note over US: Errors ignored + DW-->>API: Legacy Data + + %% Write Operations + API->>DW: Write Request + DW->>LS: Write to Legacy + LS-->>DW: Success/Error + alt Legacy Write Successful + DW->>US: Write to Unified (Background) + Note over US: Errors ignored + DW-->>API: Legacy Result + else Legacy Write Failed + DW-->>API: Legacy Error + end + + BG->>LS: Periodic Sync Check + BG->>US: Sync Missing Data +``` + +#### Mode 2: Legacy Primary + Unified Sync +```mermaid +sequenceDiagram + participant API as API Request + participant DW as Dual Writer + participant LS as Legacy Storage + participant US as Unified Storage + participant BG as Background Sync + + Note over DW: Mode 2 - Legacy Primary, Unified Synchronous + + %% Read Operations + API->>DW: Read Request + DW->>LS: Read from Legacy + LS-->>DW: Data + DW->>US: Verification Read (Foreground) + Note over US: Verifies unified storage can serve the same object + US-->>DW: Success/Error + alt Verification Read Failed (Non-NotFound) + DW-->>API: Unified Error + else Verification Read Success or NotFound + DW-->>API: Legacy Data + end + + %% Write Operations + API->>DW: Write Request + DW->>LS: Write to Legacy + LS-->>DW: Success/Error + alt Legacy Write Successful + DW->>US: Write to Unified (Foreground) + US-->>DW: Success/Error + alt Unified Write Failed + DW->>LS: Cleanup Legacy (Best Effort) + DW-->>API: Unified Error + else Both Writes Successful + DW-->>API: Legacy Result + end + else Legacy Write Failed + DW-->>API: Legacy Error + end + + BG->>LS: Periodic Sync Check + BG->>US: Sync Missing Data +``` + +#### Mode 3: Unified Primary + Legacy Sync +```mermaid +sequenceDiagram + participant API as API Request + participant DW as Dual Writer + participant LS as Legacy Storage + participant US as Unified Storage + + Note over DW: Mode 3 - Unified Primary, Legacy Sync + Note over DW: Only activated after background sync succeeds + + %% Read Operations + API->>DW: Read Request + DW->>US: Read from Unified + US-->>DW: Data/Error + alt Unified Read NotFound + DW->>LS: Fallback to Legacy + LS-->>DW: Data/Error + DW-->>API: Legacy Result + else Unified Read Success + DW-->>API: Unified Data + end + + %% Write Operations + API->>DW: Write Request + DW->>LS: Write to Legacy + LS-->>DW: Success/Error + alt Legacy Write Successful + DW->>US: Write to Unified + US-->>DW: Success/Error + alt Unified Write Failed + DW->>LS: Cleanup Legacy (Best Effort) + DW-->>API: Unified Error + else Both Writes Successful + DW-->>API: Unified Result + end + else Legacy Write Failed + DW-->>API: Legacy Error + end +``` + +#### Mode 4 & 5: Unified Only +```mermaid +sequenceDiagram + participant API as API Request + participant DW as Dual Writer + participant LS as Legacy Storage + participant US as Unified Storage + + Note over DW: Mode 4/5 - Unified Only + Note over DW: Mode 4: After background sync succeeds + Note over DW: Mode 5: Ignores background sync state + + API->>DW: Read/Write Request + DW->>US: Forward Request + US-->>DW: Response + DW-->>API: Response + + Note over LS: Not Used +``` + +### Background Sync Behavior + +The background sync service runs periodically (default: every hour) and is responsible for: + +1. **Data Synchronization**: Ensures legacy and unified storage contain the same data +2. **Mode Progression**: Enables transition from Mode 2 → Mode 3 → Mode 4 +3. **Conflict Resolution**: Handles cases where data exists in one storage but not the other + +#### Sync Process Flow + +```mermaid +flowchart TD + A[Background Sync Trigger] --> B{Current Mode} + + B -->|Mode 1/2| C[Acquire Distributed Lock] + B -->|Mode 3+| Z[No Sync Needed] + + C --> D[List Legacy Storage Items] + D --> E[List Unified Storage Items] + E --> F[Compare All Items] + + F --> G{Item Comparison} + + G -->|Missing in Unified| H[Create in Unified] + G -->|Missing in Legacy| I[Delete from Unified] + G -->|Different Content| J[Update Unified with Legacy Version] + G -->|Identical| K[No Action Needed] + + H --> L[Track Sync Success] + I --> L + J --> L + K --> L + + L --> M{All Items Synced?} + M -->|Yes| N[Mark Sync Complete
Enable Mode Progression] + M -->|No| O[Log Failures
Retry Next Cycle] + + N --> P[Release Lock] + O --> P + Z --> P +``` + +#### Mode Transition Requirements + +- **Mode 0 → Mode 1**: Configuration change only +- **Mode 1 → Mode 2**: Configuration change only +- **Mode 2 → Mode 3**: Requires successful background sync completion +- **Mode 3 → Mode 4**: Requires successful background sync completion +- **Mode 4 → Mode 5**: Configuration change only +- **Any Mode → Mode 5**: Configuration change only (bypasses sync requirements) + +### Error Handling Strategies + +#### Write Operation Error Priority +1. **Legacy Storage Errors**: Always bubble up immediately if legacy write fails +2. **Unified Storage Errors**: + - Mode 1: Logged but ignored + - Mode 2+: Bubble up after legacy cleanup attempt +3. **Cleanup Operations**: Best effort - failures are logged but don't fail the original operation + +#### Read Operation Fallback +- **Mode 2**: `NotFound` errors from unified storage are ignored (object may not be synced yet), but other errors bubble up +- **Mode 3**: If unified storage returns `NotFound`, automatically falls back to legacy storage +- **Other Modes**: No fallback - errors bubble up directly + +### Configuration + +#### Setting Dual Writer Mode +```ini +[unified_storage.{resource}.{kind}.{group}] +dualWriterMode = {0-5} +``` + +#### Background Sync Configuration +```ini +[unified_storage] +; Enable data sync between legacy and unified storage +enable_data_sync = true + +; Sync interval (default: 1 hour) +data_sync_interval = 1h + +; Maximum records to sync per run (default: 1000) +data_sync_records_limit = 1000 + +; Skip data sync requirement for mode transitions +skip_data_sync = false +``` + +### Monitoring and Observability + +The dual writer system provides metrics for monitoring: + +- `dual_writer_requests_total`: Counter of requests by mode, operation, and status +- `dual_writer_sync_duration_seconds`: Histogram of background sync duration +- `dual_writer_sync_success_total`: Counter of successful sync operations +- `dual_writer_mode_transitions_total`: Counter of mode transitions + +Use these metrics to monitor the health of your migration and identify any issues with the dual writer system. From 2e0747560539423f4a39a6c5ef5df51e1a1a92b5 Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Mon, 28 Jul 2025 08:58:13 -0500 Subject: [PATCH 02/39] docs: clarifying alert rule limits in Grafana cloud for migration assistant (#108722) --- .../migration-guide/cloud-migration-assistant.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/sources/administration/migration-guide/cloud-migration-assistant.md b/docs/sources/administration/migration-guide/cloud-migration-assistant.md index 16bb16a93c4..2ca94628b32 100644 --- a/docs/sources/administration/migration-guide/cloud-migration-assistant.md +++ b/docs/sources/administration/migration-guide/cloud-migration-assistant.md @@ -197,8 +197,20 @@ The `grafana-default-email` contact point that's provisioned with every new Graf This is sufficient to have your Alerting configuration up and running in Grafana Cloud with minimal effort. +#### Migration assistant limitations on Grafana Alerting resources + Migration of Silences is not supported by the migration assistant and needs to be configured manually. Alert History is also not available for migration. +Attempting to migrate a large number of alert rules might result in the following error: + +``` +Maximum number of alert rule groups reached: Delete some alert rule groups or upgrade your plan and try again. +``` + +To avoid this, refer to the [Alert rule limits in Grafana Cloud](https://grafana.com/docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/#alert-rule-limits-in-grafana-cloud) when migrating alert rules. + +#### Prevent duplicated alert notifications + Successfully migrating Alerting resources to your Grafana Cloud instance could result in 2 sets of notifications being generated: 1. From your OSS/Enterprise instance From 27c395694dabd7e521175b92bff735fac6aee5a0 Mon Sep 17 00:00:00 2001 From: Tania <10127682+undef1nd@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:05:57 +0200 Subject: [PATCH 03/39] OpenFeature: Initialize early (#108594) * Move OpenFeatureInit * Remove unused import * Remove todo --- pkg/cmd/grafana-server/commands/cli.go | 6 ++++++ pkg/cmd/grafana-server/commands/target.go | 5 +++++ pkg/server/server.go | 6 ------ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/grafana-server/commands/cli.go b/pkg/cmd/grafana-server/commands/cli.go index 5f438aad2fd..949c865060a 100644 --- a/pkg/cmd/grafana-server/commands/cli.go +++ b/pkg/cmd/grafana-server/commands/cli.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/grafana/grafana/pkg/services/featuremgmt" _ "github.com/grafana/pyroscope-go/godeltaprof/http/pprof" "github.com/urfave/cli/v2" @@ -105,6 +106,11 @@ func RunServer(opts standalone.BuildInfo, cli *cli.Context) error { metrics.SetBuildInformation(metrics.ProvideRegisterer(), opts.Version, opts.Commit, opts.BuildBranch, getBuildstamp(opts)) + // Initialize the OpenFeature feature flag system + if err := featuremgmt.InitOpenFeatureWithCfg(cfg); err != nil { + return err + } + s, err := server.Initialize( cfg, server.Options{ diff --git a/pkg/cmd/grafana-server/commands/target.go b/pkg/cmd/grafana-server/commands/target.go index 39f322396ac..e6988b7e4c5 100644 --- a/pkg/cmd/grafana-server/commands/target.go +++ b/pkg/cmd/grafana-server/commands/target.go @@ -7,6 +7,7 @@ import ( "runtime/debug" "strings" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/api" @@ -91,6 +92,10 @@ func RunTargetServer(opts standalone.BuildInfo, cli *cli.Context) error { metrics.SetBuildInformation(metrics.ProvideRegisterer(), opts.Version, opts.Commit, opts.BuildBranch, getBuildstamp(opts)) + // Initialize the OpenFeature client with the configuration + if err := featuremgmt.InitOpenFeatureWithCfg(cfg); err != nil { + return err + } s, err := server.InitializeModuleServer( cfg, server.Options{ diff --git a/pkg/server/server.go b/pkg/server/server.go index 794cf656763..ca1263d9e4d 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -11,7 +11,6 @@ import ( "strconv" "sync" - "github.com/grafana/grafana/pkg/services/featuremgmt" "golang.org/x/sync/errgroup" "github.com/prometheus/client_golang/prometheus" @@ -132,11 +131,6 @@ func (s *Server) Init() error { return err } - // Initialize the OpenFeature feature flag system - if err := featuremgmt.InitOpenFeatureWithCfg(s.cfg); err != nil { - return err - } - return s.provisioningService.RunInitProvisioners(s.context) } From 6aa3492f4ed096ea9369314771054b605d6255e7 Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Mon, 28 Jul 2025 09:08:17 -0500 Subject: [PATCH 04/39] docs: add video shortcode to what's new (#108793) --- docs/sources/whatsnew/whats-new-in-v12-1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/whatsnew/whats-new-in-v12-1.md b/docs/sources/whatsnew/whats-new-in-v12-1.md index 27e4f848682..00fca9ad544 100644 --- a/docs/sources/whatsnew/whats-new-in-v12-1.md +++ b/docs/sources/whatsnew/whats-new-in-v12-1.md @@ -46,7 +46,7 @@ We have one more community contributor to thank for this release. [Chris Hodges] Keep reading to learn about what else we have in store for 12.1. - +{{< youtube id=Umy-kCKkMQM >}} For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v12.1, check out our [Upgrade Guide](https://grafana.com/docs/grafana//upgrade-guide/upgrade-v12.1/). From fb53a6f077465a9d55480d917cb9ba6933d93856 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 28 Jul 2025 15:30:50 +0100 Subject: [PATCH 05/39] Chore: Remove cypress `dashboard-new-layouts` tests (#108699) remove cypress dashboard-new-layouts tests --- .github/CODEOWNERS | 2 +- .../workflows/e2e-dashboard-new-layouts.yml | 42 -- .../dashboard-duplicate-panel.spec.ts | 30 -- .../dashboard-edit-flows.ts | 58 -- .../dashboard-group-panels.spec.ts | 506 ------------------ .../dashboard-outline.spec.ts | 26 - .../dashboards-add-panel.spec.ts | 27 - .../dashboards-edit-adhoc-variables.spec.ts | 70 --- ...shboards-edit-datasource-variables.spec.ts | 49 -- ...dashboards-edit-group-by-variables.spec.ts | 68 --- ...oards-edit-panel-title-description.spec.ts | 37 -- ...shboards-edit-panel-transparent-bg.spec.ts | 26 - .../dashboards-edit-query-variables.spec.ts | 68 --- .../dashboards-edit-variables.spec.ts | 147 ----- .../dashboards-move-panel.spec.ts | 59 -- .../dashboards-panel-layouts.spec.ts | 309 ----------- .../dashboards-remove-panel.spec.ts | 35 -- .../dashboards-title-description.spec.ts | 31 -- e2e/run-suite | 23 - package.json | 2 - 20 files changed, 1 insertion(+), 1614 deletions(-) delete mode 100644 .github/workflows/e2e-dashboard-new-layouts.yml delete mode 100644 e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboard-edit-flows.ts delete mode 100644 e2e/dashboard-new-layouts/dashboard-group-panels.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboard-outline.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-add-panel.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-adhoc-variables.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-datasource-variables.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-group-by-variables.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-panel-title-description.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-move-panel.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-panel-layouts.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-remove-panel.spec.ts delete mode 100644 e2e/dashboard-new-layouts/dashboards-title-description.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3329738f79c..b2edbc703ee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -407,6 +407,7 @@ /e2e/ @grafana/grafana-frontend-platform /e2e/cloud-plugins-suite/ @grafana/partner-datasources /e2e-playwright/ @grafana/grafana-frontend-platform +/e2e-playwright/dashboard-new-layouts @grafana/dashboards-squad /e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources /e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend /e2e-playwright/test-plugins/grafana-extensionstest-app/ @grafana/plugins-platform-frontend @@ -1013,7 +1014,6 @@ embed.go @grafana/grafana-as-code /.github/workflows/verify-kinds.yml @grafana/platform-monitoring /.github/workflows/dashboards-issue-add-label.yml @grafana/dashboards-squad /.github/workflows/run-schema-v2-e2e.yml @grafana/dashboards-squad -/.github/workflows/e2e-dashboard-new-layouts.yml @grafana/dashboards-squad /.github/workflows/run-dashboard-search-e2e.yml @grafana/grafana-search-and-storage /.github/workflows/trigger-dashboard-search-e2e.yml @grafana/grafana-search-and-storage /.github/workflows/ephemeral-instances-pr-comment.yml @grafana/grafana-operator-experience-squad diff --git a/.github/workflows/e2e-dashboard-new-layouts.yml b/.github/workflows/e2e-dashboard-new-layouts.yml deleted file mode 100644 index b2b5d1f8215..00000000000 --- a/.github/workflows/e2e-dashboard-new-layouts.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Run e2e for dashboardNewLayouts - -on: - pull_request: - branches: - - '**' - paths: - - 'e2e/dashboard-new-layouts/**' - - 'public/app/features/dashboard-scene/**' - -env: - ARCH: linux-amd64 - -jobs: - dashboard-new-layouts-e2e: - runs-on: ubuntu-latest - continue-on-error: true - if: github.event.pull_request.draft == false - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Pin Go version to mod file - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - run: go version - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - name: Install dependencies - run: yarn install --immutable - - name: Build grafana - run: make build - - name: Install Cypress dependencies - uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f - with: - runTests: false - - name: Run dashboardNewLayouts e2e - run: yarn e2e:dashboard-new-layouts diff --git a/e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts b/e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts deleted file mode 100644 index 43ac0576f47..00000000000 --- a/e2e/dashboard-new-layouts/dashboard-duplicate-panel.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { e2e } from '../utils'; - -import { flows } from './dashboard-edit-flows'; - -describe('Dashboard panels', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can duplicate a panel', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Paste tab' }); - - e2e.flows.scenes.toggleEditMode(); - const panelTitle = 'Unique'; - flows.changePanelTitle('New panel', panelTitle); - - e2e.components.Panels.Panel.title(panelTitle).should('have.length', 1); - - e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); - e2e.components.Panels.Panel.menuItems('More...').trigger('mouseover'); - e2e.components.Panels.Panel.menuItems('Duplicate').click(); - - e2e.components.Panels.Panel.title(panelTitle).should('have.length', 2); - - // Save, reload, and ensure duplicate has persisted - e2e.flows.scenes.saveDashboard(); - cy.reload(); - e2e.components.Panels.Panel.title(panelTitle).should('have.length', 2); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboard-edit-flows.ts b/e2e/dashboard-new-layouts/dashboard-edit-flows.ts deleted file mode 100644 index c823d97fd0f..00000000000 --- a/e2e/dashboard-new-layouts/dashboard-edit-flows.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { e2e } from '../utils'; - -const deselectPanels = () => { - e2e.pages.Dashboard.Controls().click(); -}; - -// Common flows for adding/editing variables on the new edit pane -export const flows = { - newEditPaneVariableClick() { - e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click(); - e2e.components.PanelEditor.Outline.section().should('be.visible').click(); - e2e.components.PanelEditor.Outline.item('Variables').should('be.visible').click(); - e2e.components.PanelEditor.ElementEditPane.addVariableButton().should('be.visible').click(); - }, - newEditPanelCommonVariableInputs(variable: Variable) { - e2e.components.PanelEditor.ElementEditPane.variableType(variable.type) - .scrollIntoView() - .should('be.visible') - .click(); - e2e.components.PanelEditor.ElementEditPane.variableNameInput().clear().type(variable.name).blur(); - e2e.components.PanelEditor.ElementEditPane.variableLabelInput().clear().type(variable.label).blur(); - }, - firstPanelTitleShouldBe(panelTitle: string) { - return e2e.components.Panels.Panel.headerContainer() - .first() - .within(() => cy.get('h2').first().should('have.text', panelTitle)); - }, - deselectPanels, - changePanelTitle(oldPanelTitle: string, newPanelTitle: string) { - deselectPanels(); - const oldPanelRegex = new RegExp(`^${oldPanelTitle}$`); - e2e.flows.scenes.selectPanel(oldPanelRegex); - - e2e.components.PanelEditor.OptionsPane.fieldInput('Title') - .should('have.value', oldPanelTitle) - .clear() - .type(newPanelTitle); - e2e.components.PanelEditor.OptionsPane.fieldInput('Title').should('have.value', newPanelTitle); - }, - changePanelDescription(panelTitle: string, newDescription: string) { - deselectPanels(); - const panelTitleRegex = new RegExp(`^${panelTitle}$`); - e2e.flows.scenes.selectPanel(panelTitleRegex); - - e2e.components.PanelEditor.OptionsPane.fieldLabel('panel-options Description').within(() => { - cy.get('textarea').type(newDescription); - cy.get('textarea').should('have.value', newDescription); - }); - }, -}; - -export type Variable = { - type: string; - name: string; - label?: string; - description?: string; - value: string; -}; diff --git a/e2e/dashboard-new-layouts/dashboard-group-panels.spec.ts b/e2e/dashboard-new-layouts/dashboard-group-panels.spec.ts deleted file mode 100644 index f3d74c171b5..00000000000 --- a/e2e/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { e2e } from '../utils'; - -describe('Grouping panels', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - /* - * Rows - */ - - it('can group and ungroup new panels into row', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into row' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Group into row - e2e.flows.scenes.groupIntoRow(); - - // Verify row and panel titles - e2e.components.DashboardRow.title('New row').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify row and panel titles after reload - e2e.components.DashboardRow.title('New row').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Ungroup - e2e.flows.scenes.ungroupPanels(); - - // Verify Row title is gone - e2e.components.DashboardRow.title('New row').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - //Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify Row title is gone - e2e.components.DashboardRow.title('New row').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can add and remove several rows', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Add and remove rows' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoRow(); - - e2e.components.CanvasGridAddActions.addRow().click({ scrollBehavior: 'bottom' }); - e2e.flows.scenes.addPanel(); - - e2e.components.CanvasGridAddActions.addRow().click({ scrollBehavior: 'bottom' }); - e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false }); - e2e.components.CanvasGridAddActions.addPanel().should('have.length', 3).last().click(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.DashboardRow.title('New row 2').should('exist'); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 5); - - //Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.DashboardRow.title('New row 2').should('exist'); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 5); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.DashboardRow.title('New row 1').parent().click(); - e2e.components.EditPaneHeader.deleteButton().click(); - e2e.pages.ConfirmModal.delete().click(); - - e2e.components.DashboardRow.title('New row 2').parent().click(); - e2e.components.EditPaneHeader.deleteButton().click(); - e2e.pages.ConfirmModal.delete().click(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('not.exist'); - e2e.components.DashboardRow.title('New row 2').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('not.exist'); - e2e.components.DashboardRow.title('New row 2').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can paste a copied row', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Paste row' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoRow(); - - e2e.components.DashboardRow.title('New row').should('exist'); - - e2e.flows.scenes.editPaneCopy(); - - e2e.components.CanvasGridAddActions.pasteRow().click({ scrollBehavior: 'bottom' }); - - e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false }); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 6); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - cy.scrollTo('bottom'); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 6); - }); - - it('can duplicate a row', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Duplicate row' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoRow(); - - e2e.components.DashboardRow.title('New row').should('exist'); - - e2e.flows.scenes.editPaneDuplicate(); - - e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false }); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 6); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - cy.scrollTo('bottom'); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 6); - }); - - it('can collapse rows', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Collapse rows' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoRow(); - - e2e.components.DashboardRow.title('New row').should('exist'); - - e2e.flows.scenes.editPaneDuplicate(); - - e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false }); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 6); - - e2e.components.DashboardRow.title('New row').click(); - e2e.components.DashboardRow.title('New row 1').click(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 0); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 0); - }); - - it('can convert rows into tabs when changing layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Rows to tabs' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoRow(); - - e2e.components.DashboardRow.title('New row').should('exist'); - - e2e.flows.scenes.editPaneDuplicate(); - - e2e.components.DashboardRow.title('New row').should('exist'); - e2e.components.DashboardRow.title('New row 1').should('exist'); - - e2e.components.EditPaneHeader.backButton().click({ force: true }); - - // expand collapsed layouts section - e2e.components.OptionsGroup.toggle('group-layout-category').click(); - - e2e.flows.scenes.selectTabsLayout(); - - e2e.components.Tab.title('New row').should('be.visible'); - e2e.components.Tab.title('New row 1').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.Tab.title('New row 1').click(); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Tab.title('New row').should('be.visible'); - e2e.components.Tab.title('New row 1').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.Tab.title('New row').click(); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can group and ungroup new panels into row with tab', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into tab with row' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Group into row with tab - e2e.flows.scenes.groupIntoRow(); - e2e.flows.scenes.groupIntoTab(); - - // Verify tab and panel titles - e2e.components.DashboardRow.title('New row').should('be.visible'); - e2e.components.Tab.title('New tab').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify tab, row and panel titles after reload - e2e.components.DashboardRow.title('New row').should('be.visible'); - e2e.components.Tab.title('New tab').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Ungroup - e2e.flows.scenes.ungroupPanels(); // ungroup tabs - e2e.flows.scenes.ungroupPanels(); // ungroup rows - - // Verify tab and row titles is gone - e2e.components.DashboardRow.title('New row').should('not.exist'); - e2e.components.Tab.title('New tab').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify Row title is gone - e2e.components.DashboardRow.title('New row').should('not.exist'); - e2e.components.Tab.title('New tab').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - /* - * Tabs - */ - - it('can group and ungroup new panels into tab', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into tab' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Group into tab - e2e.flows.scenes.groupIntoTab(); - - // Verify tab and panel titles - e2e.components.Tab.title('New tab').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify row and panel titles after reload - e2e.components.Tab.title('New tab').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Ungroup - e2e.flows.scenes.ungroupPanels(); - - // Verify Row title is gone - e2e.components.Tab.title('New tab').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify Row title is gone - e2e.components.Tab.title('New tab').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can add and remove several tabs', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Add and remove tabs' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoTab(); - - e2e.components.CanvasGridAddActions.addTab().click(); - e2e.flows.scenes.addPanel(); - - e2e.components.CanvasGridAddActions.addTab().click(); - e2e.flows.scenes.addPanel(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Tab.title('New tab 2').should('exist'); - e2e.components.Tab.title('New tab 2').should('have.attr', 'aria-selected', 'true'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 1); - - //Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Tab.title('New tab 2').should('exist'); - e2e.components.Tab.title('New tab 2').should('have.attr', 'aria-selected', 'true'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 1); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Tab.title('New tab 2').click(); - e2e.components.EditPaneHeader.deleteButton().click(); - e2e.pages.ConfirmModal.delete().click(); - - e2e.components.Tab.title('New tab 1').click(); - e2e.components.EditPaneHeader.deleteButton().click(); - e2e.pages.ConfirmModal.delete().click(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('not.exist'); - e2e.components.Tab.title('New tab 2').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('not.exist'); - e2e.components.Tab.title('New tab 2').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can paste a copied tab', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Paste tab' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoTab(); - - e2e.components.Tab.title('New tab').should('exist'); - - e2e.flows.scenes.editPaneCopy(); - - e2e.components.CanvasGridAddActions.pasteTab().click(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can duplicate a tab', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Duplicate tab' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoTab(); - - e2e.components.Tab.title('New tab').should('exist'); - - e2e.flows.scenes.editPaneDuplicate(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); - - it('can convert tabs into rows when changing layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Tabs to rows' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.flows.scenes.groupIntoTab(); - - e2e.components.Tab.title('New tab').should('exist'); - - e2e.flows.scenes.editPaneDuplicate(); - e2e.flows.scenes.editPaneDuplicate(); - - e2e.components.Tab.title('New tab').should('exist'); - e2e.components.Tab.title('New tab 1').should('exist'); - e2e.components.Tab.title('New tab 2').should('exist'); - - e2e.components.EditPaneHeader.backButton().click({ force: true }); - - // expand collapsed layouts section - e2e.components.OptionsGroup.toggle('group-layout-category').click(); - - e2e.flows.scenes.selectRowsLayout(); - - e2e.components.DashboardRow.title('New tab').should('exist'); - e2e.components.Panels.Panel.title('New panel').first().should('be.visible'); // wait for panels to load - e2e.components.DashboardRow.title('New tab 1').should('exist'); - e2e.components.DashboardRow.title('New tab 2').should('exist'); - - e2e.components.DashboardEditPaneSplitter.primaryBody().scrollTo('bottom', { ensureScrollable: false }); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 9); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.DashboardRow.title('New tab').should('exist'); - e2e.components.Panels.Panel.title('New panel').first().should('be.visible'); // wait for panels to load - e2e.components.DashboardRow.title('New tab 1').should('exist'); - e2e.components.DashboardRow.title('New tab 2').should('exist'); - - cy.scrollTo('bottom'); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 9); - }); - - it('can group and ungroup new panels into tab with row', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Group new panels into tab with row' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Group into tab - e2e.flows.scenes.groupIntoTab(); - e2e.flows.scenes.groupIntoRow(); - - // Verify tab and panel titles - e2e.components.Tab.title('New tab').should('be.visible'); - e2e.components.DashboardRow.title('New row').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify tab, row and panel titles after reload - e2e.components.Tab.title('New tab').should('be.visible'); - e2e.components.DashboardRow.title('New row').should('be.visible'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - // Ungroup - e2e.flows.scenes.ungroupPanels(); // ungroup rows - e2e.flows.scenes.ungroupPanels(); // ungroup tabs - - // Verify tab and row titles is gone - e2e.components.Tab.title('New tab').should('not.exist'); - e2e.components.DashboardRow.title('New row').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - // Save dashboards and reload - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - // Verify Row title is gone - e2e.components.Tab.title('New tab').should('not.exist'); - e2e.components.DashboardRow.title('New row').should('not.exist'); - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboard-outline.spec.ts b/e2e/dashboard-new-layouts/dashboard-outline.spec.ts deleted file mode 100644 index 6068152514c..00000000000 --- a/e2e/dashboard-new-layouts/dashboard-outline.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { e2e } from '../utils'; - -const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; - -describe('Dashboard Outline', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can use dashboard outline', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - e2e.components.PanelEditor.Outline.section().click(); - - // Should be able to click Variables item in outline to see add variable button - e2e.components.PanelEditor.Outline.item('Variables').click(); - e2e.components.PanelEditor.ElementEditPane.addVariableButton().should('exist'); - - // Clicking a panel should scroll that panel in view - cy.contains('Dashboard panel 48').should('not.exist'); - e2e.components.PanelEditor.Outline.item('Panel #48').click(); - cy.contains('Dashboard panel 48').should('exist'); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-add-panel.spec.ts b/e2e/dashboard-new-layouts/dashboards-add-panel.spec.ts deleted file mode 100644 index 9997351cfdd..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-add-panel.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { e2e } from '../utils'; - -const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; -const DASHBOARD_NAME = 'Test variable output'; - -describe('Dashboard panels', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can add a new panel', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - // Toggle edit mode - e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click(); - - e2e.flows.scenes.addPanel(); - - // Check that new panel has been added - e2e.components.Panels.Panel.title('New panel').should('be.visible'); - - // Check that pressing the configure button shows the panel editor - e2e.flows.scenes.configurePanel(); - e2e.components.PanelEditor.General.content().should('be.visible'); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-adhoc-variables.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-adhoc-variables.spec.ts deleted file mode 100644 index f7350331978..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-adhoc-variables.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { e2e } from '../utils'; - -import { flows, Variable } from './dashboard-edit-flows'; - -const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; -const DASHBOARD_NAME = 'Test variable output'; - -describe('Dashboard edit - Ad hoc variables', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can add a new adhoc variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const variable: Variable = { - type: 'adhoc', - name: 'VariableUnderTest', - value: 'label1', - label: 'VariableUnderTest', - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - e2e.pages.Dashboard.Settings.Variables.Edit.AdHocFiltersVariable.datasourceSelect().should('be.visible').click(); - const dataSource = 'gdev-loki'; - cy.contains(dataSource).scrollIntoView().should('be.visible').click(); - - // mock the API call to get the labels - const labels = ['label1', 'label2']; - cy.intercept('GET', '**/resources/labels*', { - statusCode: 200, - body: { - status: 'success', - data: labels, - }, - }).as('labels'); - - // select the variable in the dashboard and confirm the variable value is set - e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); - - // mock the API call to get the label values - const labelValues = ['label2Value1']; - cy.intercept('GET', `**/resources/label/${labels[1]}/values*`, { - statusCode: 200, - body: { - status: 'success', - data: labelValues, - }, - }).as('label-values'); - - // choose the label and value - cy.get('div[data-testid]').contains(labels[1]).click(); - cy.get('div[data-testid]').contains('=').click(); - cy.get('div[data-testid]').contains(labelValues[0]).click(); - cy.focused().type('{esc}'); - - // assert the panel is visible and has the correct value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${labels[1]}="${labelValues[0]}"`); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-datasource-variables.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-datasource-variables.spec.ts deleted file mode 100644 index 25b8b8608ac..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-datasource-variables.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { e2e } from '../utils'; - -import { flows, Variable } from './dashboard-edit-flows'; - -const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; -const DASHBOARD_NAME = 'Test variable output'; - -describe('Dashboard edit - datasource variables', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can add a new datasource variable', () => { - e2e.pages.Dashboards.visit(); - - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const dsType = 'cloudwatch'; - - const variable: Variable = { - type: 'datasource', - name: 'VariableUnderTest', - label: 'VariableUnderTest', - value: `gdev-${dsType}`, - }; - - // Common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect().should('be.visible').click(); - cy.get(`#combobox-option-${dsType}`).click(); - - const regexFilter = 'cloud'; - e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.nameFilter().should('be.visible').type(regexFilter); - - // Assert the variable dropdown is visible with correct label - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); - - // Assert the variable values are correctly displayed in the panel - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `${variable.name}: ${variable.value}`); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-group-by-variables.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-group-by-variables.spec.ts deleted file mode 100644 index 9af0f6d9e8f..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-group-by-variables.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { e2e } from '../utils'; - -import { flows, Variable } from './dashboard-edit-flows'; - -const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; -const DASHBOARD_NAME = 'Test variable output'; - -describe('Dashboard edit - Group By variables', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can add a new group by variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const variable: Variable = { - type: 'groupby', - name: 'VariableUnderTest', - value: 'label1', - label: 'VariableUnderTest', - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - e2e.pages.Dashboard.Settings.Variables.Edit.GroupByVariable.dataSourceSelect().should('be.visible').click(); - const dataSource = 'gdev-loki'; - cy.contains(dataSource).scrollIntoView().should('be.visible').click(); - - // mock the API call to get the labels - const labels = ['label1', 'label2']; - cy.intercept('GET', '**/resources/labels*', { - statusCode: 200, - body: { - status: 'success', - data: labels, - }, - }).as('labels'); - - // select the variable in the dashboard and confirm the variable value is set - e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); - - // mock the API call to get the label values - const labelValues = ['label2Value1']; - cy.intercept('GET', `**/resources/label/${labels[1]}/values*`, { - statusCode: 200, - body: { - status: 'success', - data: labelValues, - }, - }).as('label-values'); - - // choose the label and value - cy.get('div[data-testid]').contains(labels[1]).click(); - cy.focused().type('{esc}'); - - // assert the panel is visible and has the correct value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${labels[1]}`); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-panel-title-description.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-panel-title-description.spec.ts deleted file mode 100644 index a7122c53902..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-panel-title-description.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { e2e } from '../utils'; - -import { flows } from './dashboard-edit-flows'; - -const PAGE_UNDER_TEST = '5SdHCadmz/panel-tests-graph'; - -describe('Dashboard', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can edit panel title and description', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - const oldTitle = 'No Data Points Warning'; - flows.firstPanelTitleShouldBe(oldTitle); - - const newDescription = 'A description of this panel'; - flows.changePanelDescription(oldTitle, newDescription); - - const newTitle = 'New Panel Title'; - flows.changePanelTitle(oldTitle, newTitle); - - // Check that new title is reflected in panel header - flows.firstPanelTitleShouldBe(newTitle); - - // Reveal description tooltip and check that its value is as expected - const descriptionIcon = () => cy.get('[data-testid="title-items-container"] > span').first(); - descriptionIcon().click({ force: true }); - descriptionIcon().then((el) => { - const tooltipId = el.attr('aria-describedby'); - cy.get(`[id="${tooltipId}"]`).should('have.text', `${newDescription}\n`); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts deleted file mode 100644 index ca6c5df485b..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { e2e } from '../utils'; - -const PAGE_UNDER_TEST = '5SdHCadmz/panel-tests-graph'; - -describe('Dashboard', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can toggle transparent background switch', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - e2e.flows.scenes.selectPanel(/^No Data Points Warning$/); - - e2e.components.Panels.Panel.title('No Data Points Warning').then((el) => { - cy.wrap(el.css('background')).should('not.match', /rgba\(0, 0, 0, 0\)/); - }); - - cy.get('#transparent-background').click({ force: true }); - e2e.components.Panels.Panel.title('No Data Points Warning').then((el) => { - cy.wrap(el.css('background')).should('match', /rgba\(0, 0, 0, 0\)/); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts deleted file mode 100644 index 47e445a3421..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-query-variables.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { e2e } from '../utils'; - -import { flows, Variable } from './dashboard-edit-flows'; - -const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; -const DASHBOARD_NAME = 'Test variable output'; - -describe('Dashboard edit - Query variable', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can add a new query variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const queryVariableOptions = ['default']; - - const variable: Variable = { - type: 'query', - name: 'VariableUnderTest', - value: queryVariableOptions[0], - label: 'VariableUnderTest', // constant doesn't really need a label - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - // open the modal query variable editor - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsOpenButton().should('be.visible').click(); - // select a core data source that just runs a query during preview - e2e.components.DataSourcePicker.container().should('be.visible').click(); - - // spy on the API call to get the query options - cy.intercept('GET', '/api/datasources/**').as('getOptions'); - - const dataSource = 'gdev-cloudwatch'; - // this will trigger an API call to get the query options - cy.contains(dataSource).scrollIntoView().should('be.visible').click(); - // wait for the API call to finish - cy.wait('@getOptions'); - // show the preview of the query results - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.previewButton().should('be.visible').click(); - // assert the query results are shown - e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('be.visible'); - e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() - .first() - .then(($el) => { - const previewOption = $el.text().trim(); - cy.wrap(previewOption).as('previewOption'); - }); - - // close the modal - e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.closeButton().should('be.visible').click(); - // assert the query variable values are in the variable value select - cy.get('@previewOption').then((opt) => { - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.name).next().should('have.text', opt); - // assert the panel is visible and has the correct value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${opt}`); - }); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts b/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts deleted file mode 100644 index 37737b65c6a..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-edit-variables.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { e2e } from '../utils'; - -import { flows, Variable } from './dashboard-edit-flows'; - -const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; -const DASHBOARD_NAME = 'Test variable output'; - -describe('Dashboard edit - variables', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can add a new custom variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const variable: Variable = { - type: 'custom', - name: 'foo', - label: 'Foo', - value: 'one,two,three', - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - // set the custom variable value - e2e.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput().clear().type(variable.value).blur(); - - // assert the dropdown for the variable is visible and has the correct values - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); - const values = variable.value.split(','); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(values[0]).should('be.visible'); - - // check that variable deletion works - e2e.components.EditPaneHeader.deleteButton().click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('not.exist'); - }); - - it('can add a new constant variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const variable: Variable = { - type: 'constant', - name: 'VariableUnderTest', - value: 'foo', - label: 'VariableUnderTest', // constant doesn't really need a label - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - // set the constant variable value - const type = 'variable-type Value'; - const field = e2e.components.PanelEditor.OptionsPane.fieldLabel(type); - field.should('be.visible'); - field.find('input').should('be.visible').clear().type(variable.value).blur(); - - // assert the panel is visible and has the correct value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${variable.value}`); - }); - }); - - it('can add a new textbox variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const variable: Variable = { - type: 'textbox', - name: 'VariableUnderTest', - value: 'foo', - label: 'VariableUnderTest', - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - // set the textbox variable value - const type = 'variable-type Value'; - const field = e2e.components.PanelEditor.OptionsPane.fieldLabel(type); - field.should('be.visible'); - field.find('input').should('be.visible').clear().type(variable.value).blur(); - - // select the variable in the dashboard and confirm the variable value is set - e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); - - // assert the panel is visible and has the correct value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${variable.value}`); - }); - }); - - it('can add a new interval variable', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - cy.contains(DASHBOARD_NAME).should('be.visible'); - - const variable: Variable = { - type: 'interval', - name: 'VariableUnderTest', - value: '1m', - label: 'VariableUnderTest', - }; - - // common steps to add a new variable - flows.newEditPaneVariableClick(); - flows.newEditPanelCommonVariableInputs(variable); - - // enable the auto option - e2e.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.autoEnabledCheckbox().click({ force: true }); - - // select the variable in the dashboard and confirm the variable value is set - e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible').click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.label).should('be.visible').contains(variable.label); - - // assert the panel is visible and has the correct value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: ${variable.value}`); - }); - - // select the variable in the dashboard and set the Auto option - e2e.pages.Dashboard.SubMenu.submenuItemLabels(variable.name).next().should('have.text', `1m`).click(); - e2e.components.Select.option().contains('Auto').click(); - - // assert the panel is visible and has the correct "Auto" value - e2e.components.Panels.Panel.content() - .should('be.visible') - .first() - .within(() => { - cy.get('.markdown-html').should('include.text', `VariableUnderTest: 10m`); - }); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-move-panel.spec.ts b/e2e/dashboard-new-layouts/dashboards-move-panel.spec.ts deleted file mode 100644 index 431a75dd54b..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-move-panel.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { e2e } from '../utils'; - -const PAGE_UNDER_TEST = 'ed155665/annotation-filtering'; - -describe('Dashboard', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can drag and drop panels', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - e2e.flows.scenes.movePanel(/^Panel three$/, /^Panel one$/); - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel three$/) - .then((panel3) => { - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel one$/) - .should('be.lowerThan', panel3); - }); - - e2e.flows.scenes.movePanel(/^Panel two$/, /^Panel three$/); - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel three$/) - .then((panel3) => { - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel two$/) - .should('be.higherThan', panel3); - }); - }); - - // Note, moving a panel from a nested row to a parent row currently just deletes the panel - // This test will need to be updated once the correct behavior is implemented. - it('can move panel from nested row to parent row', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - e2e.flows.scenes.groupIntoRow(); - e2e.flows.scenes.groupIntoRow(); - - cy.get('[data-testid="data-testid dashboard-row-title-New row"]') - .first() - .then((el) => { - const rect = el.offset(); - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel one$/) - .trigger('pointerdown', { which: 1 }) - .trigger('pointermove', { clientX: rect.left, clientY: rect.top }) - .trigger('pointerup'); - }); - - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel one$/) - .should('not.exist'); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-panel-layouts.spec.ts b/e2e/dashboard-new-layouts/dashboards-panel-layouts.spec.ts deleted file mode 100644 index 49f4e1bcc3e..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-panel-layouts.spec.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { e2e } from '../utils'; - -describe('Dashboard', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can switch to auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Switch to auto grid' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - const checkInputs = () => { - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible'); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns().should('be.visible'); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible'); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen().should('exist'); - }; - - checkInputs(); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - checkInputs(); - }); - - it('can change min column width in auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Set min column width' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - let firstStandardPanelTopOffset = 0; - - // standard min column width will have 1 panel on a second row in edit mode - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - firstStandardPanelTopOffset = el.offset().top; - }); - - e2e.components.Panels.Panel.title('New panel') - .last() - .then((el) => { - expect(el.offset().top).to.be.greaterThan(firstStandardPanelTopOffset); - }); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible').click(); - cy.get('[id=combobox-option-narrow]').click(); - - const checkOffset = () => { - // narrow min column width will have all panels on the same row - let narrowPanelTopOffset = 0; - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - narrowPanelTopOffset = el.offset().top; - }); - - e2e.components.Panels.Panel.title('New panel') - .last() - .then((el) => { - expect(el.offset().top).to.eq(narrowPanelTopOffset); - }); - }; - - checkOffset(); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('have.value', 'Narrow'); - - checkOffset(); - }); - - it('can change to custom min column width in auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Set custom min column width' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible').click(); - cy.get('[id=combobox-option-custom]').click(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth() - .should('be.visible') - .clear() - .type('900') - .blur(); - - cy.wait(100); // cy too fast and executes next command before resizing is done - - // // changing to 900 custom width to have each panel span the whole row to verify offset - e2e.flows.scenes.verifyPanelsStackedVertically(); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.flows.scenes.verifyPanelsStackedVertically(); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth().should('have.value', '900'); - - e2e.flows.scenes.verifyPanelsStackedVertically(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.clearCustomMinColumnWidth().should('be.visible').click(); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('have.value', 'Standard'); - }); - - it('can change max columns in auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Set max columns' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns().should('be.visible').click(); - cy.get('[id=combobox-option-1]').click(); - - // changing to 1 max column to have each panel span the whole row to verify offset - e2e.flows.scenes.verifyPanelsStackedVertically(); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.flows.scenes.verifyPanelsStackedVertically(); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns().should('have.value', '1'); - - e2e.flows.scenes.verifyPanelsStackedVertically(); - }); - - it('can change row height in auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Set row height' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - let regularRowHeight = 0; - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - regularRowHeight = el.height(); - }); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible').click(); - cy.get('[id=combobox-option-short]').click(); - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - expect(el.height()).to.be.lessThan(regularRowHeight); - }); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible').click(); - cy.get('[id=combobox-option-tall]').click(); - - const checkHeight = () => { - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - expect(el.height()).to.be.greaterThan(regularRowHeight); - }); - }; - - checkHeight(); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - checkHeight(); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('have.value', 'Tall'); - - checkHeight(); - }); - - it('can change to custom row height in auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Set custom row height' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - let regularRowHeight = 0; - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - regularRowHeight = el.height(); - }); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('be.visible').click(); - cy.get('[id=combobox-option-custom]').click(); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customRowHeight().clear().type('800').blur(); - cy.wait(100); // cy too fast and executes next command before resizing is done - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - const elHeight = el.height(); - expect(elHeight).be.closeTo(800, 5); // some flakyness and get 798 sometimes - expect(elHeight).to.be.greaterThan(regularRowHeight); - }); - - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - expect(el.height()).be.closeTo(800, 5); // some flakyness and get 798 sometimes - }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.customRowHeight().should('have.value', '800'); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.clearCustomRowHeight().should('be.visible').click(); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight().should('have.value', 'Standard'); - }); - - it('can change fill screen in auto grid layout', () => { - e2e.flows.scenes.importV2Dashboard({ title: 'Set fill screen' }); - - e2e.components.NavToolbar.editDashboard.editButton().click(); - - e2e.components.Panels.Panel.title('New panel').should('have.length', 3); - - e2e.components.OptionsGroup.toggle('grid-layout-category').click(); - - e2e.flows.scenes.selectAutoGridLayout(); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth().should('be.visible').click(); - cy.get('[id=combobox-option-narrow]').click(); - - let initialHeight = 0; - - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - initialHeight = el.height(); - }); - - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen().click({ force: true }); - - const checkHeight = () => { - e2e.components.Panels.Panel.title('New panel') - .first() - .then((el) => { - expect(el.height()).to.be.greaterThan(initialHeight); - }); - }; - - checkHeight(); - e2e.flows.scenes.saveDashboard(); - cy.reload(); - - checkHeight(); - e2e.components.NavToolbar.editDashboard.editButton().click(); - e2e.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen().should('be.checked'); - - checkHeight(); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-remove-panel.spec.ts b/e2e/dashboard-new-layouts/dashboards-remove-panel.spec.ts deleted file mode 100644 index 7256a4f4740..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-remove-panel.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { e2e } from '../utils'; - -const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; - -describe('Dashboard panels', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can remove a panel', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - e2e.flows.scenes.removePanels(/^Panel #1$/); - - // Check that panel has been deleted - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel #1$/) - .should('not.exist'); - }); - - it('can remove several panels at once', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - e2e.flows.scenes.removePanels(/^Panel #1$/, /^Panel #2$/, /^Panel #3$/); - - // Check that panels have been deleted - e2e.components.Panels.Panel.headerContainer() - .contains(/^Panel #[123]$/) - .should('not.exist'); - }); -}); diff --git a/e2e/dashboard-new-layouts/dashboards-title-description.spec.ts b/e2e/dashboard-new-layouts/dashboards-title-description.spec.ts deleted file mode 100644 index 2ae8eb28f3e..00000000000 --- a/e2e/dashboard-new-layouts/dashboards-title-description.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { e2e } from '../utils'; - -const PAGE_UNDER_TEST = 'ed155665/annotation-filtering'; - -describe('Dashboard', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - }); - - it('can change dashboard description and title', () => { - e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1` }); - - e2e.flows.scenes.toggleEditMode(); - - // Check that current dashboard title is visible in breadcrumb - cy.get('[aria-label="Breadcrumbs"]').contains('Annotation filtering').should('exist'); - - const titleInput = () => cy.get('[aria-label="dashboard-options Title field property editor"] input'); - titleInput().should('have.value', 'Annotation filtering').clear().type('New dashboard title'); - titleInput().should('have.value', 'New dashboard title'); - - // Check that new dashboard title is reflected in breadcrumb - cy.get('[aria-label="Breadcrumbs"]').contains('New dashboard title').should('exist'); - - // Check that we can successfully change the dashboard description - const descriptionTextArea = () => - cy.get('[aria-label="dashboard-options Description field property editor"] textarea'); - descriptionTextArea().clear().type('Dashboard description'); - descriptionTextArea().should('have.value', 'Dashboard description'); - }); -}); diff --git a/e2e/run-suite b/e2e/run-suite index 386fbae4f0e..3303918e1fd 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -30,7 +30,6 @@ rootForEnterpriseSuite="./e2e/extensions" rootForOldArch="./e2e/old-arch" rootForKubernetesDashboards="./e2e/dashboards-suite" rootForSearchDashboards="./e2e/dashboards-search-suite" -rootForDashboardNewLayouts="./e2e/dashboard-new-layouts" declare -A cypressConfig=( [screenshotsFolder]=./e2e/"${args[0]}"/screenshots @@ -148,28 +147,6 @@ case "$1" in ;; esac ;; - "dashboard-new-layouts") - env[kubernetesDashboards]=true - env[dashboardNewLayouts]=true - env[groupByVariable]=true - cypressConfig[specPattern]=$rootForDashboardNewLayouts/$testFilesForSingleSuite - cypressConfig[video]=false - case "$2" in - "debug") - echo -e "Debug mode" - env[SLOWMO]=1 - PARAMS="--no-exit" - enterpriseSuite=$(basename "${args[2]}") - ;; - "dev") - echo "Dev mode" - # remove comment to run in slomo ( demo mode ) - # env[SLOWMO]=1 - CMD="cypress open" - enterpriseSuite=$(basename "${args[2]}") - ;; - esac - ;; "enterprise-smtp") env[SMTP_PLUGIN_ENABLED]=true cypressConfig[specPattern]=./e2e/extensions/enterprise/smtp-suite/$testFilesForSingleSuite diff --git a/package.json b/package.json index f647fe32435..34741ebf2a2 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,6 @@ "e2e:old-arch": "./e2e/start-and-run-suite old-arch", "e2e:schema-v2": "./e2e/start-and-run-suite dashboards-schema-v2", "e2e:dashboards-search": "./e2e/start-and-run-suite dashboards-search", - "e2e:dashboard-new-layouts": "./e2e/start-and-run-suite dashboard-new-layouts", - "e2e:dashboard-new-layouts:dev": "./e2e/start-and-run-suite dashboard-new-layouts dev", "e2e:debug": "./e2e/start-and-run-suite debug", "e2e:dev": "./e2e/start-and-run-suite dev", "e2e:benchmark:live": "./e2e/start-and-run-suite benchmark live", From 6b4d93b8ecf95a6df0a97598e5c1a9cf2596e5f8 Mon Sep 17 00:00:00 2001 From: Adam Simpson Date: Mon, 28 Jul 2025 10:42:32 -0400 Subject: [PATCH 06/39] querier: check for headers to force expr parsing (#108701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gábor Farkas --- pkg/services/query/query.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index d3f05438e45..e2c3f68043d 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/mtdsclient" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" @@ -97,6 +98,12 @@ func (s *ServiceImpl) Run(ctx context.Context) error { // QueryData processes queries and returns query responses. It handles queries to single or mixed datasources, as well as expressions. func (s *ServiceImpl) QueryData(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) { + fromAlert := false + for header, val := range s.headers { + if header == models.FromAlertHeaderName && val == "true" { + fromAlert = true + } + } // Parse the request into parsed queries grouped by datasource uid parsedReq, err := s.parseMetricRequest(ctx, user, skipDSCache, reqDTO) if err != nil { @@ -104,7 +111,7 @@ func (s *ServiceImpl) QueryData(ctx context.Context, user identity.Requester, sk } // If there are expressions, handle them and return - if parsedReq.hasExpression { + if parsedReq.hasExpression || fromAlert { return s.handleExpressions(ctx, user, parsedReq) } // If there is only one datasource, query it and return From 4c6888654cf0e225c84b446e931c03a63c60cfdb Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Mon, 28 Jul 2025 17:55:28 +0300 Subject: [PATCH 07/39] Provisioning: Re-fetch folders after creating or deleting a repository (#108778) * refetch folder * Simplify refetches * Cleanup * Tests * More test mocks * Function selectors * Comment * Remove unused selector --- .../clients/provisioning/v0alpha1/index.ts | 13 ++++++++ .../scene/NavToolbarActions.tsx | 2 +- .../Wizard/ProvisioningWizard.test.tsx | 4 +++ .../features/provisioning/utils/selectors.ts | 31 +++++++------------ 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts index c92490184b1..eb200d83d01 100644 --- a/public/app/api/clients/provisioning/v0alpha1/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -3,6 +3,8 @@ import { isFetchError } from '@grafana/runtime'; import { notifyApp } from '../../../../core/actions'; import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification'; +import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; +import { refetchChildren } from '../../../../features/browse-dashboards/state/actions'; import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded'; import { @@ -59,6 +61,12 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ ); } } + // Refetch dashboards and folders after deleting a provisioned repository. + // We need to add timeout to ensure that the deletion is processed before refetching since the deletion is done + // via a background job. + setTimeout(() => { + dispatch(refetchChildren({ parentUID: undefined, pageSize: PAGE_SIZE })); + }, 1000); }, }, deletecollectionRepository: { @@ -84,6 +92,9 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ ); } } + setTimeout(() => { + dispatch(refetchChildren({ parentUID: undefined, pageSize: PAGE_SIZE })); + }, 1000); }, }, createRepositoryTest: { @@ -189,6 +200,8 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ ); } } + // Refetch dashboards and folders after creating/updating a provisioned repository + dispatch(refetchChildren({ parentUID: undefined, pageSize: PAGE_SIZE })); }, }, }, diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 49b4a3c9f53..00dbd0943a3 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -73,7 +73,7 @@ export function ToolbarActions({ dashboard }: Props) { // Means we are not in settings view, fullscreen panel or edit panel const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; const isEditingAndShowingDashboard = isEditing && isShowingDashboard; - const folderRepo = useSelector((state) => selectFolderRepository(state, meta.folderUid)); + const folderRepo = useSelector((state) => selectFolderRepository()(state, meta.folderUid)); const isManaged = Boolean(dashboard.isManagedRepository() || folderRepo); // Internal only; diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx index 83fc75ddfb9..f9c293137d9 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx @@ -38,6 +38,10 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useCreateRepositoryJobsMutation: jest.fn(), })); +jest.mock('app/features/browse-dashboards/api/services', () => ({ + PAGE_SIZE: 20, +})); + const mockUseCreateOrUpdateRepository = useCreateOrUpdateRepository as jest.MockedFunction< typeof useCreateOrUpdateRepository >; diff --git a/public/app/features/provisioning/utils/selectors.ts b/public/app/features/provisioning/utils/selectors.ts index 1e88f0b9530..372d3567e2b 100644 --- a/public/app/features/provisioning/utils/selectors.ts +++ b/public/app/features/provisioning/utils/selectors.ts @@ -1,28 +1,21 @@ import { createSelector } from '@reduxjs/toolkit'; -import { RootState } from 'app/store/configureStore'; - import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from '../../../api/clients/provisioning/v0alpha1'; const emptyRepos: Repository[] = []; -const baseSelector = provisioningAPI.endpoints.listRepository.select({}); +const getBaseSelector = () => provisioningAPI.endpoints.listRepository.select({}); -export const selectAllRepos = createSelector(baseSelector, (result) => result.data?.items || emptyRepos); +export const selectAllRepos = () => createSelector(getBaseSelector(), (result) => result.data?.items || emptyRepos); -export const selectFolderRepository = createSelector( - selectAllRepos, - (_, folderUid?: string) => folderUid, - (repositories: Repository[], folderUid) => { - if (!folderUid) { - return undefined; +export const selectFolderRepository = () => + createSelector( + selectAllRepos(), + (_, folderUid?: string) => folderUid, + (repositories: Repository[], folderUid) => { + if (!folderUid) { + return undefined; + } + return repositories.find((repo: Repository) => repo.metadata?.name === folderUid); } - return repositories.find((repo: Repository) => repo.metadata?.name === folderUid); - } -); - -export const selectRepoByName = createSelector( - selectAllRepos, - (state: RootState, id: string) => id, - (repositories: Repository[], name) => repositories.find((repo: Repository) => repo.metadata?.name === name) -); + ); From e261d5f14a9f4fdd40c787f99980936b228332c5 Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Mon, 28 Jul 2025 10:58:21 -0400 Subject: [PATCH 08/39] CloudWatch: Clear log groups when region is changed (#108727) --- .../QueryEditor/QueryHeader.test.tsx | 20 +++++++++++++++---- .../components/QueryEditor/QueryHeader.tsx | 4 ++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.test.tsx index 01af5b18121..5658dedf92f 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.test.tsx @@ -26,14 +26,19 @@ describe('QueryHeader', () => { { value: 'us-east-2', label: 'us-east-2' }, { value: 'us-east-1', label: 'us-east-1' }, ]); - it('should reset account id if new region is not monitoring account', async () => { + it('should reset account id and log groups if new region is not monitoring account', async () => { config.featureToggles.cloudWatchCrossAccountQuerying = true; const onChange = jest.fn(); datasource.resources.isMonitoringAccount = jest.fn().mockResolvedValue(false); render( { ...validMetricSearchBuilderQuery, region: 'us-east-2', accountId: undefined, + logGroups: [], }); }); - it('should not reset account id if new region is a monitoring account', async () => { + it('should reset log groups but not account id if new region is a monitoring account', async () => { config.featureToggles.cloudWatchCrossAccountQuerying = true; const onChange = jest.fn(); datasource.resources.isMonitoringAccount = jest.fn().mockResolvedValue(true); @@ -56,7 +62,12 @@ describe('QueryHeader', () => { render( { ...validMetricSearchBuilderQuery, region: 'us-east-2', accountId: '123', + logGroups: [], }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx index d252f1fa78d..57568a0666e 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx @@ -47,9 +47,9 @@ const QueryHeader = ({ const onRegionChange = async (region: string) => { if (config.featureToggles.cloudWatchCrossAccountQuerying && isCloudWatchMetricsQuery(query)) { const isMonitoringAccount = await datasource.resources.isMonitoringAccount(region); - onChange({ ...query, region, accountId: isMonitoringAccount ? query.accountId : undefined }); + onChange({ ...query, logGroups: [], region, accountId: isMonitoringAccount ? query.accountId : undefined }); } else { - onChange({ ...query, region }); + onChange({ ...query, logGroups: [], region }); } }; From 2d3fde46074f936743c39759d0b5844be60e5365 Mon Sep 17 00:00:00 2001 From: Angel Kozlev Date: Mon, 28 Jul 2025 16:21:36 +0100 Subject: [PATCH 09/39] Pyroscope: Remove LegacyForms from ConfigEditor (#104973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pyroscope: Remove LegacyForms from ConfigEditor * Pyroscope: Align fields in form * Pyroscope: Add id to input and label for a11y * Update public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx --------- Co-authored-by: Piotr Jamróz Co-authored-by: Joey --- .../ConfigEditor.tsx | 64 ++++++++----------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx index 3b2e3cbb5d0..f3442a6a52d 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx @@ -11,15 +11,7 @@ import { convertLegacyAuthProps, } from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; -import { - Divider, - EventsWithValidation, - LegacyForms, - SecureSocksProxySettings, - Stack, - regexValidation, - useStyles2, -} from '@grafana/ui'; +import { Divider, Field, Input, SecureSocksProxySettings, Stack, useStyles2 } from '@grafana/ui'; import { PyroscopeDataSourceOptions } from './types'; @@ -56,7 +48,7 @@ export const ConfigEditor = (props: Props) => { isCollapsible={true} isInitiallyOpen={false} > - + {config.secureSocksDSProxyEnabled && ( @@ -64,36 +56,30 @@ export const ConfigEditor = (props: Props) => { )} - { - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - minStep: event.currentTarget.value, - }, - }); - }} - validationEvents={{ - [EventsWithValidation.onBlur]: [ - regexValidation( - /^$|^\d+(ms|[Mwdhmsy])$/, - 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s' - ), - ], - }} - /> - } - tooltip="Minimal step used for metric query. Should be the same or higher as the scrape interval setting in the Pyroscope database." - /> + htmlFor="minimal-step" + description="Minimal step used for metric query. Should be the same or higher as the scrape interval setting in the Pyroscope database." + error="Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s" + invalid={!!options.jsonData.minStep && !/^\d+(ms|[Mwdhmsy])$/.test(options.jsonData.minStep)} + > + { + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + minStep: event.currentTarget.value, + }, + }); + }} + /> + From d5fb158ebd3c8b943949a981e4c4948145373b1d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:43:04 +0100 Subject: [PATCH 10/39] Update dependency rollup to v4.46.1 (#108792) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 182 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 101 insertions(+), 81 deletions(-) diff --git a/yarn.lock b/yarn.lock index 99cfd2d8de6..3b83aff5bd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6865,128 +6865,142 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.26.0" +"@rollup/rollup-android-arm-eabi@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.46.1" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-android-arm64@npm:4.26.0" +"@rollup/rollup-android-arm64@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-android-arm64@npm:4.46.1" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.26.0" +"@rollup/rollup-darwin-arm64@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-darwin-arm64@npm:4.46.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.26.0" +"@rollup/rollup-darwin-x64@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-darwin-x64@npm:4.46.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.26.0" +"@rollup/rollup-freebsd-arm64@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.46.1" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.26.0" +"@rollup/rollup-freebsd-x64@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-freebsd-x64@npm:4.46.1" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.26.0" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.46.1" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.26.0" +"@rollup/rollup-linux-arm-musleabihf@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.46.1" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.26.0" +"@rollup/rollup-linux-arm64-gnu@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.46.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.26.0" +"@rollup/rollup-linux-arm64-musl@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.46.1" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.26.0" +"@rollup/rollup-linux-loongarch64-gnu@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.46.1" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.46.1" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.26.0" +"@rollup/rollup-linux-riscv64-gnu@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.46.1" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.26.0" +"@rollup/rollup-linux-riscv64-musl@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.46.1" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.46.1" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.26.0" +"@rollup/rollup-linux-x64-gnu@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.46.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.26.0" +"@rollup/rollup-linux-x64-musl@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.46.1" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.26.0" +"@rollup/rollup-win32-arm64-msvc@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.46.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.26.0" +"@rollup/rollup-win32-ia32-msvc@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.46.1" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.26.0": - version: 4.26.0 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.26.0" +"@rollup/rollup-win32-x64-msvc@npm:4.46.1": + version: 4.46.1 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.46.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -9435,10 +9449,10 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:*, @types/estree@npm:1.0.6, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5, @types/estree@npm:^1.0.6": - version: 1.0.6 - resolution: "@types/estree@npm:1.0.6" - checksum: 10/9d35d475095199c23e05b431bcdd1f6fec7380612aed068b14b2a08aa70494de8a9026765a5a91b1073f636fb0368f6d8973f518a31391d519e20c59388ed88d +"@types/estree@npm:*, @types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5, @types/estree@npm:^1.0.6": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10/25a4c16a6752538ffde2826c2cc0c6491d90e69cd6187bef4a006dd2c3c45469f049e643d7e516c515f21484dc3d48fd5c870be158a5beb72f5baf3dc43e4099 languageName: node linkType: hard @@ -28225,28 +28239,30 @@ __metadata: linkType: hard "rollup@npm:^4.22.4": - version: 4.26.0 - resolution: "rollup@npm:4.26.0" + version: 4.46.1 + resolution: "rollup@npm:4.46.1" dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.26.0" - "@rollup/rollup-android-arm64": "npm:4.26.0" - "@rollup/rollup-darwin-arm64": "npm:4.26.0" - "@rollup/rollup-darwin-x64": "npm:4.26.0" - "@rollup/rollup-freebsd-arm64": "npm:4.26.0" - "@rollup/rollup-freebsd-x64": "npm:4.26.0" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.26.0" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.26.0" - "@rollup/rollup-linux-arm64-gnu": "npm:4.26.0" - "@rollup/rollup-linux-arm64-musl": "npm:4.26.0" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.26.0" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.26.0" - "@rollup/rollup-linux-s390x-gnu": "npm:4.26.0" - "@rollup/rollup-linux-x64-gnu": "npm:4.26.0" - "@rollup/rollup-linux-x64-musl": "npm:4.26.0" - "@rollup/rollup-win32-arm64-msvc": "npm:4.26.0" - "@rollup/rollup-win32-ia32-msvc": "npm:4.26.0" - "@rollup/rollup-win32-x64-msvc": "npm:4.26.0" - "@types/estree": "npm:1.0.6" + "@rollup/rollup-android-arm-eabi": "npm:4.46.1" + "@rollup/rollup-android-arm64": "npm:4.46.1" + "@rollup/rollup-darwin-arm64": "npm:4.46.1" + "@rollup/rollup-darwin-x64": "npm:4.46.1" + "@rollup/rollup-freebsd-arm64": "npm:4.46.1" + "@rollup/rollup-freebsd-x64": "npm:4.46.1" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.46.1" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.46.1" + "@rollup/rollup-linux-arm64-gnu": "npm:4.46.1" + "@rollup/rollup-linux-arm64-musl": "npm:4.46.1" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.46.1" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.46.1" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.46.1" + "@rollup/rollup-linux-riscv64-musl": "npm:4.46.1" + "@rollup/rollup-linux-s390x-gnu": "npm:4.46.1" + "@rollup/rollup-linux-x64-gnu": "npm:4.46.1" + "@rollup/rollup-linux-x64-musl": "npm:4.46.1" + "@rollup/rollup-win32-arm64-msvc": "npm:4.46.1" + "@rollup/rollup-win32-ia32-msvc": "npm:4.46.1" + "@rollup/rollup-win32-x64-msvc": "npm:4.46.1" + "@types/estree": "npm:1.0.8" fsevents: "npm:~2.3.2" dependenciesMeta: "@rollup/rollup-android-arm-eabi": @@ -28269,10 +28285,14 @@ __metadata: optional: true "@rollup/rollup-linux-arm64-musl": optional: true - "@rollup/rollup-linux-powerpc64le-gnu": + "@rollup/rollup-linux-loongarch64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-gnu": optional: true "@rollup/rollup-linux-riscv64-gnu": optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true "@rollup/rollup-linux-s390x-gnu": optional: true "@rollup/rollup-linux-x64-gnu": @@ -28289,7 +28309,7 @@ __metadata: optional: true bin: rollup: dist/bin/rollup - checksum: 10/aec4d876617298400c0c03d35fed67e5193addc82a76f2b2a2f4c2b000cafbca84a33cf2e686dea1d1caa06fe4028dd94b8e6cd1f5bc3bbd19026a188bb2ec55 + checksum: 10/dc79db54312e895acc8dc0f0b2ef7e507d9ee1f742944ed060f10c17010076f60df13a46baed66780cede9ccaa604dfc87edfbe0d0c47c63b32e66a647c0f5c8 languageName: node linkType: hard From 2ee0f93e8c64c054af924c8425d718843f4fb985 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:09:18 +0000 Subject: [PATCH 11/39] Update scenes to v6.28.2 (#108809) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3b83aff5bd6..9ac783e535e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3601,10 +3601,10 @@ __metadata: linkType: soft "@grafana/scenes-react@npm:^6.27.2": - version: 6.28.1 - resolution: "@grafana/scenes-react@npm:6.28.1" + version: 6.28.2 + resolution: "@grafana/scenes-react@npm:6.28.2" dependencies: - "@grafana/scenes": "npm:6.28.1" + "@grafana/scenes": "npm:6.28.2" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3616,13 +3616,13 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/03278682a8ff7ebb399d522b3f54aee820cb34ab85f79d321ee44c4fbb1a490b6d62a8f03cae68187a70cc4345715d41441a72ae3915e6bebfe20fbfb61f9108 + checksum: 10/c00730312828639f8a596c9fd9b0336ec57ca5cec624ac4f09a5fa0be93282d180451e3bcbf83c0209ff04486def25ff98d7b0ea6c370e91b8990112c3e903f2 languageName: node linkType: hard -"@grafana/scenes@npm:6.28.1, @grafana/scenes@npm:^6.27.2": - version: 6.28.1 - resolution: "@grafana/scenes@npm:6.28.1" +"@grafana/scenes@npm:6.28.2, @grafana/scenes@npm:^6.27.2": + version: 6.28.2 + resolution: "@grafana/scenes@npm:6.28.2" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3642,7 +3642,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/603cb2b421e59a51ee36af6f1d6554e6f328db3aea42da7960e33c14de4ce298b2c261affa6dd651fe8d4ffb4a58e7825958a3b66d00daa3410e91f5f1fb185f + checksum: 10/53370553ac4ac38d41ab1d782ee14cf05f483e35e5338406d619efb61e4c9881fd361c98ee116ae8bf40ab1a5fa2c82ca859fc519f6d532853c275e71949c654 languageName: node linkType: hard From 2dd655a50d4e0d3501d47356d700b07e9f43e837 Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:14:30 -0700 Subject: [PATCH 12/39] Correlations: Fix flaky test (#108618) * chore: fix flaky test * chore: remove assert in equal, use require instead * chore: skip flaky test --- pkg/tests/api/correlations/correlations_update_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/tests/api/correlations/correlations_update_test.go b/pkg/tests/api/correlations/correlations_update_test.go index 8da638344d7..53e092e9b68 100644 --- a/pkg/tests/api/correlations/correlations_update_test.go +++ b/pkg/tests/api/correlations/correlations_update_test.go @@ -217,6 +217,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { }) t.Run("updating a correlation pointing to a read-only data source should work", func(t *testing.T) { + t.Skip("flaky test") correlation := ctx.createCorrelation(correlations.CreateCorrelationCommand{ SourceUID: writableDs, TargetUID: &writableDs, From b32a6b008801d85f8face032cfef92a49bf24663 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 28 Jul 2025 17:21:10 +0100 Subject: [PATCH 13/39] Chore: Remove `smoke-tests-suite` from cypress (#108700) * remove smoke-tests-suite from cypress * restore shared/smokeTestScenario for enterprise --- .github/workflows/pr-e2e-tests.yml | 2 - e2e/smoke-tests-suite/1-smoketests.spec.ts | 3 -- .../panels_smokescreen.spec.ts | 38 ------------------- e2e/verify/specs/smoketests.spec.ts | 3 -- 4 files changed, 46 deletions(-) delete mode 100644 e2e/smoke-tests-suite/1-smoketests.spec.ts delete mode 100644 e2e/smoke-tests-suite/panels_smokescreen.spec.ts delete mode 100644 e2e/verify/specs/smoketests.spec.ts diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index 215a4016344..30590d458fe 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -143,8 +143,6 @@ jobs: path: e2e/various-suite - suite: dashboards-suite path: e2e/dashboards-suite - - suite: smoke-tests-suite - path: e2e/smoke-tests-suite - suite: panels-suite path: e2e/panels-suite - suite: various-suite (old arch) diff --git a/e2e/smoke-tests-suite/1-smoketests.spec.ts b/e2e/smoke-tests-suite/1-smoketests.spec.ts deleted file mode 100644 index b66a6eee28f..00000000000 --- a/e2e/smoke-tests-suite/1-smoketests.spec.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { smokeTestScenario } from '../shared/smokeTestScenario'; - -smokeTestScenario(); diff --git a/e2e/smoke-tests-suite/panels_smokescreen.spec.ts b/e2e/smoke-tests-suite/panels_smokescreen.spec.ts deleted file mode 100644 index ca63dadba19..00000000000 --- a/e2e/smoke-tests-suite/panels_smokescreen.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { GrafanaBootConfig } from '@grafana/runtime'; - -import { e2e } from '../utils'; - -describe('Panels smokescreen', () => { - beforeEach(() => { - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), false); - }); - - after(() => { - e2e.flows.revertAllChanges(); - }); - - it('Tests each panel type in the panel edit view to ensure no crash', () => { - e2e.flows.addDashboard(); - - e2e.flows.addPanel({ - dataSourceName: 'gdev-testdata', - timeout: 10000, - visitDashboardAtStart: false, - }); - - cy.window().then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => { - // Loop through every panel type and ensure no crash - Object.entries(win.grafanaBootData.settings.panels).forEach(([_, panel]) => { - // TODO: Remove Flame Graph check as part of addressing #66803 - if (!panel.hideFromList && panel.state !== 'deprecated') { - e2e.components.PanelEditor.toggleVizPicker().click(); - e2e.components.PluginVisualization.item(panel.name).scrollIntoView().should('be.visible').click(); - - e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain(panel.name)); - // TODO: Come up with better check / better failure messaging to clearly indicate which panel failed - cy.contains('An unexpected error happened').should('not.exist'); - } - }); - }); - }); -}); diff --git a/e2e/verify/specs/smoketests.spec.ts b/e2e/verify/specs/smoketests.spec.ts deleted file mode 100644 index 39409d544ba..00000000000 --- a/e2e/verify/specs/smoketests.spec.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { smokeTestScenario } from '../../shared/smokeTestScenario'; - -smokeTestScenario(); From 672e6d08bf0b6295c2ff3fcc6ae83a697a5cd20a Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 28 Jul 2025 17:25:02 +0100 Subject: [PATCH 14/39] Chore: Remove old storybook-verification cypress test (#108696) * add check for frontend code changing * remove cache * add to pr-e2e-tests instead * fix CODEOWNERS * remove cypress test --- .github/CODEOWNERS | 2 - .github/workflows/pr-e2e-tests.yml | 29 +++++++++++ .../storybook-verification-playwright.yml | 47 ----------------- .github/workflows/storybook-verification.yml | 52 ------------------- e2e/storybook/verify.spec.ts | 14 ----- 5 files changed, 29 insertions(+), 115 deletions(-) delete mode 100644 .github/workflows/storybook-verification-playwright.yml delete mode 100644 .github/workflows/storybook-verification.yml delete mode 100644 e2e/storybook/verify.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b2edbc703ee..4cf7c2c931f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1004,8 +1004,6 @@ embed.go @grafana/grafana-as-code /.github/workflows/scripts/json-file-to-job-output.js @grafana/plugins-platform-frontend /.github/workflows/stale.yml @grafana/grafana-developer-enablement-squad /.github/workflows/storybook-a11y.yml @grafana/grafana-frontend-platform -/.github/workflows/storybook-verification.yml @grafana/grafana-frontend-platform -/.github/workflows/storybook-verification-playwright.yml @grafana/grafana-frontend-platform /.github/workflows/update-make-docs.yml @grafana/docs-tooling /.github/workflows/scripts/kinds/verify-kinds.go @grafana/platform-monitoring /.github/workflows/scripts/create-security-branch/create-security-branch.sh @grafana/grafana-developer-enablement-squad diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index 30590d458fe..ce7703d2b76 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -196,6 +196,34 @@ jobs: path: videos retention-days: 1 + run-storybook-test: + name: Verify Storybook (Playwright) + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Install dependencies + run: yarn install --immutable + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run Storybook and E2E tests + run: yarn e2e:playwright:storybook + run-playwright-tests: needs: - build-grafana @@ -232,6 +260,7 @@ jobs: required-playwright-tests: needs: - run-playwright-tests + - run-storybook-test - build-grafana if: ${{ !cancelled() }} name: All Playwright tests complete diff --git a/.github/workflows/storybook-verification-playwright.yml b/.github/workflows/storybook-verification-playwright.yml deleted file mode 100644 index e3924a67a85..00000000000 --- a/.github/workflows/storybook-verification-playwright.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Verify Storybook (Playwright) - -on: - pull_request: - paths: - - 'packages/grafana-ui/**' - - 'e2e-playwright/storybook/**' - - '!docs/**' - - '!*.md' - push: - branches: - - main - paths: - - 'packages/grafana-ui/**' - - 'e2e-playwright/storybook/**' - - '!docs/**' - - '!*.md' - -permissions: {} - -jobs: - verify-storybook: - name: Verify Storybook (Playwright) - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Install dependencies - run: yarn install --immutable - - - name: Install Playwright browsers - run: npx playwright install --with-deps - - - name: Run Storybook and E2E tests - run: yarn e2e:playwright:storybook diff --git a/.github/workflows/storybook-verification.yml b/.github/workflows/storybook-verification.yml deleted file mode 100644 index 2777836d5cb..00000000000 --- a/.github/workflows/storybook-verification.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Verify Storybook - -on: - pull_request: - paths: - - 'packages/grafana-ui/**' - - '!docs/**' - - '!*.md' - push: - branches: - - main - paths: - - 'packages/grafana-ui/**' - - '!docs/**' - - '!*.md' - -permissions: {} - -jobs: - verify-storybook: - name: Verify Storybook - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Install dependencies - run: yarn install --immutable - - - name: Run Storybook and E2E tests - uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f - with: - browser: chrome - start: yarn storybook --quiet - wait-on: 'http://localhost:9001' - wait-on-timeout: 60 - command: yarn e2e:storybook - install: false - env: - HOST: localhost - PORT: 9001 diff --git a/e2e/storybook/verify.spec.ts b/e2e/storybook/verify.spec.ts deleted file mode 100644 index f6210d890d1..00000000000 --- a/e2e/storybook/verify.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -// very basic test to verify that the button story loads correctly -// this is only intended to catch some basic build errors with storybook -// NOTE: storybook must already be running (`yarn storybook`) for this test to work -describe('Verify storybook', () => { - it('Loads the button story correctly', () => { - cy.visit('?path=/story/inputs-button--basic'); - getIframeBody().find('button:contains("Example button")').should('be.visible'); - }); -}); - -// see https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress -function getIframeBody() { - return cy.get('#storybook-preview-iframe').its('0.contentDocument.body').should('not.be.empty').then(cy.wrap); -} From a009da2087c473e33d56461f74f4ccd503fd27c7 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 28 Jul 2025 17:32:18 +0100 Subject: [PATCH 15/39] Playwright: Acceptance tests (#108770) * create a set of acceptance tests to run with bench * move tests back, fix login tests to work with supplied credentials: * rename files again * rename skip message --- e2e-playwright/scenarios/login.spec.ts | 19 --------- .../{panels-smokescreen.spec.ts => panels.ts} | 28 +++++++------ ...-smoketests.spec.ts => smoketests.spec.ts} | 2 +- e2e-playwright/unauthenticated/login.spec.ts | 40 +++++++++++++++++++ package.json | 1 + playwright.config.ts | 4 +- 6 files changed, 59 insertions(+), 35 deletions(-) delete mode 100644 e2e-playwright/scenarios/login.spec.ts rename e2e-playwright/smoke-tests-suite/{panels-smokescreen.spec.ts => panels.ts} (62%) rename e2e-playwright/smoke-tests-suite/{1-smoketests.spec.ts => smoketests.spec.ts} (98%) create mode 100644 e2e-playwright/unauthenticated/login.spec.ts diff --git a/e2e-playwright/scenarios/login.spec.ts b/e2e-playwright/scenarios/login.spec.ts deleted file mode 100644 index afef3d867d1..00000000000 --- a/e2e-playwright/scenarios/login.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test, expect } from '@grafana/plugin-e2e'; - -test( - 'Scenario test: Can login successfully', - { - tag: ['@scenarios'], - }, - async ({ selectors, page }) => { - await page.goto(selectors.pages.Login.url); - - await page.getByTestId(selectors.pages.Login.username).fill('admin'); - await page.getByTestId(selectors.pages.Login.password).fill('admin'); - await page.getByTestId(selectors.pages.Login.submit).click(); - - await page.getByTestId(selectors.pages.Login.skip).click(); - - await expect(page.getByTestId(selectors.components.NavToolbar.commandPaletteTrigger)).toBeVisible(); - } -); diff --git a/e2e-playwright/smoke-tests-suite/panels-smokescreen.spec.ts b/e2e-playwright/smoke-tests-suite/panels.ts similarity index 62% rename from e2e-playwright/smoke-tests-suite/panels-smokescreen.spec.ts rename to e2e-playwright/smoke-tests-suite/panels.ts index 61e17bec7c6..c65bd0d28ce 100644 --- a/e2e-playwright/smoke-tests-suite/panels-smokescreen.spec.ts +++ b/e2e-playwright/smoke-tests-suite/panels.ts @@ -4,7 +4,7 @@ import { GrafanaBootConfig } from '@grafana/runtime'; test.describe( 'Panels smokescreen', { - tag: ['@smoke'], + tag: ['@acceptance'], }, () => { test('Tests each panel type in the panel edit view to ensure no crash', async ({ @@ -14,6 +14,7 @@ test.describe( }) => { // this test can absolutely take longer than the default 30s timeout test.setTimeout(60000); + // Create new dashboard const dashboardPage = await gotoDashboardPage({}); @@ -30,19 +31,20 @@ test.describe( // Loop through every panel type and ensure no crash for (const [_, panel] of Object.entries(panelTypes)) { - // Skip hidden and deprecated panels - if (!panel.hideFromList && panel.state !== 'deprecated') { - // Open visualization picker - const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); - await vizPicker.click(); - await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click(); - - // Verify panel type is selected - await expect(vizPicker).toHaveText(panel.name); - - // Ensure no unexpected error occurred - await expect(page.getByText('An unexpected error happened')).toBeHidden(); + if (panel.hideFromList || panel.state === 'deprecated') { + continue; // Skip hidden and deprecated panels } + + // Select the panel type in the viz picker + const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); + await vizPicker.click(); + await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click(); + + // Verify panel type is selected + await expect(vizPicker).toHaveText(panel.name); + + // Ensure no unexpected error occurred + await expect(page.getByText('An unexpected error happened')).toBeHidden(); } }); } diff --git a/e2e-playwright/smoke-tests-suite/1-smoketests.spec.ts b/e2e-playwright/smoke-tests-suite/smoketests.spec.ts similarity index 98% rename from e2e-playwright/smoke-tests-suite/1-smoketests.spec.ts rename to e2e-playwright/smoke-tests-suite/smoketests.spec.ts index a6bea288915..2fcd785b22a 100644 --- a/e2e-playwright/smoke-tests-suite/1-smoketests.spec.ts +++ b/e2e-playwright/smoke-tests-suite/smoketests.spec.ts @@ -5,7 +5,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.describe( 'Smoke tests', { - tag: ['@smoke'], + tag: ['@acceptance'], }, () => { test('Login, create test data source, create dashboard and panel scenario', async ({ diff --git a/e2e-playwright/unauthenticated/login.spec.ts b/e2e-playwright/unauthenticated/login.spec.ts new file mode 100644 index 00000000000..2cd226134bb --- /dev/null +++ b/e2e-playwright/unauthenticated/login.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test( + 'Can login successfully', + { + tag: ['@acceptance'], + }, + async ({ selectors, page, grafanaAPICredentials }) => { + test.skip(grafanaAPICredentials.password === 'admin', 'Does not run with default password'); + + await page.goto(selectors.pages.Login.url); + + await page.getByTestId(selectors.pages.Login.username).fill(grafanaAPICredentials.user); + await page.getByTestId(selectors.pages.Login.password).fill(grafanaAPICredentials.password); + + await page.getByTestId(selectors.pages.Login.submit).click(); + + await expect(page.getByTestId(selectors.components.NavToolbar.commandPaletteTrigger)).toBeVisible(); + } +); + +test( + 'Can login successfully and skip password change', + { + tag: ['@acceptance'], + }, + async ({ selectors, page, grafanaAPICredentials }) => { + test.skip(grafanaAPICredentials.password !== 'admin', 'Only runs with the default password'); + + await page.goto(selectors.pages.Login.url); + + await page.getByTestId(selectors.pages.Login.username).fill(grafanaAPICredentials.user); + await page.getByTestId(selectors.pages.Login.password).fill(grafanaAPICredentials.password); + + await page.getByTestId(selectors.pages.Login.submit).click(); + await page.getByTestId(selectors.pages.Login.skip).click(); + + await expect(page.getByTestId(selectors.components.NavToolbar.commandPaletteTrigger)).toBeVisible(); + } +); diff --git a/package.json b/package.json index 34741ebf2a2..931e39234b2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug", "e2e:playwright": "yarn playwright test", "e2e:playwright:storybook": "yarn playwright test -c playwright.storybook.config.ts", + "e2e:acceptance": "yarn playwright test --grep @acceptance", "e2e:storybook": "PORT=9001 ./e2e/run-suite storybook true", "e2e:plugin:build": "nx run-many -t build --projects='@test-plugins/*'", "e2e:plugin:build:dev": "nx run-many -t dev --projects='@test-plugins/*' --maxParallel=100", diff --git a/playwright.config.ts b/playwright.config.ts index b569bc6feea..a3388e20c5e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -205,8 +205,8 @@ export default defineConfig({ dependencies: ['authenticate'], }, { - name: 'scenarios', - testDir: path.join(testDirRoot, '/scenarios'), + name: 'unauthenticated', + testDir: path.join(testDirRoot, '/unauthenticated'), use: { ...devices['Desktop Chrome'], }, From aa7ae5fc65e41321ad5aa7400ac79f3c00b6ed68 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:35:20 -0400 Subject: [PATCH 16/39] unified-storage: add tracing to distributor methods (#108791) * add tracing to distributor methods --- .../unified/resource/search_server_distributor.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/storage/unified/resource/search_server_distributor.go b/pkg/storage/unified/resource/search_server_distributor.go index 14527f0e6b3..1e1c00e7a7e 100644 --- a/pkg/storage/unified/resource/search_server_distributor.go +++ b/pkg/storage/unified/resource/search_server_distributor.go @@ -33,6 +33,7 @@ func ProvideSearchDistributorServer(cfg *setting.Cfg, features featuremgmt.Featu log: log.New("index-server-distributor"), ring: ring, clientPool: ringClientPool, + tracing: tracer, } healthService, err := ProvideHealthService(distributorServer) @@ -80,6 +81,7 @@ type distributorServer struct { clientPool *ringclient.Pool ring *ring.Ring log log.Logger + tracing trace.Tracer } var ( @@ -92,6 +94,8 @@ var ( ) func (ds *distributorServer) Search(ctx context.Context, r *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) { + ctx, span := ds.tracing.Start(ctx, "distributor.Search") + defer span.End() ctx, client, err := ds.getClientToDistributeRequest(ctx, r.Options.Key.Namespace, "Search") if err != nil { return nil, err @@ -101,6 +105,8 @@ func (ds *distributorServer) Search(ctx context.Context, r *resourcepb.ResourceS } func (ds *distributorServer) GetStats(ctx context.Context, r *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) { + ctx, span := ds.tracing.Start(ctx, "distributor.GetStats") + defer span.End() ctx, client, err := ds.getClientToDistributeRequest(ctx, r.Namespace, "GetStats") if err != nil { return nil, err @@ -110,6 +116,8 @@ func (ds *distributorServer) GetStats(ctx context.Context, r *resourcepb.Resourc } func (ds *distributorServer) CountManagedObjects(ctx context.Context, r *resourcepb.CountManagedObjectsRequest) (*resourcepb.CountManagedObjectsResponse, error) { + ctx, span := ds.tracing.Start(ctx, "distributor.CountManagedObjects") + defer span.End() ctx, client, err := ds.getClientToDistributeRequest(ctx, r.Namespace, "CountManagedObjects") if err != nil { return nil, err @@ -119,6 +127,8 @@ func (ds *distributorServer) CountManagedObjects(ctx context.Context, r *resourc } func (ds *distributorServer) ListManagedObjects(ctx context.Context, r *resourcepb.ListManagedObjectsRequest) (*resourcepb.ListManagedObjectsResponse, error) { + ctx, span := ds.tracing.Start(ctx, "distributor.ListManagedObjects") + defer span.End() ctx, client, err := ds.getClientToDistributeRequest(ctx, r.Namespace, "ListManagedObjects") if err != nil { return nil, err From f41570a6f73cf98964b5ed9d78b5d2e46cc7b4fb Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 28 Jul 2025 11:37:17 -0500 Subject: [PATCH 17/39] Annotations: Move to integration tests (#108736) --- .../accesscontrol/accesscontrol_test.go | 228 --------- .../annotationsimpl/annotations_test.go | 368 --------------- .../annotationsimpl/xorm_store_test.go | 5 + pkg/tests/api/annotations/annotations_test.go | 439 ++++++++++++++++++ 4 files changed, 444 insertions(+), 596 deletions(-) delete mode 100644 pkg/services/annotations/accesscontrol/accesscontrol_test.go delete mode 100644 pkg/services/annotations/annotationsimpl/annotations_test.go create mode 100644 pkg/tests/api/annotations/annotations_test.go diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go deleted file mode 100644 index f4ec20ffcfa..00000000000 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package accesscontrol - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/annotations/testutil" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" - dashboardsservice "github.com/grafana/grafana/pkg/services/dashboards/service" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/tests/testsuite" -) - -func TestMain(m *testing.M) { - testsuite.Run(m) -} - -func TestIntegrationAuthorize(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - sql, cfg := db.InitTestDBWithCfg(t) - folderStore := folderimpl.ProvideDashboardFolderStore(sql) - fStore := folderimpl.ProvideStore(sql) - dashStore, err := database.ProvideDashboardStore(sql, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql)) - require.NoError(t, err) - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, - nil, sql, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), - ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(sql, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore()) - require.NoError(t, err) - dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService()) - - u := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - } - - dash1, err := dashSvc.SaveDashboard(context.Background(), &dashboards.SaveDashboardDTO{ - User: u, - OrgID: 1, - Dashboard: &dashboards.Dashboard{ - Title: "Dashboard 1", - Data: simplejson.New(), - }, - }, false) - require.NoError(t, err) - - dash2, err := dashSvc.SaveDashboard(context.Background(), &dashboards.SaveDashboardDTO{ - User: u, - OrgID: 1, - Dashboard: &dashboards.Dashboard{ - Title: "Dashboard 2", - Data: simplejson.New(), - }, - }, false) - require.NoError(t, err) - - role := testutil.SetupRBACRole(t, sql, u) - - type testCase struct { - name string - permissions map[string][]string - featureToggle string - expectedResources *AccessResources - expectedErr error - } - - testCases := []testCase{ - { - name: "should have both scopes and all dashboards", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID, dash2.UID: dash2.ID}, - CanAccessOrgAnnotations: true, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have no dashboards if missing annotation read permission on dashboards and FlagAnnotationPermissionUpdate is enabled", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - featureToggle: featuremgmt.FlagAnnotationPermissionUpdate, - expectedResources: &AccessResources{ - Dashboards: nil, - CanAccessOrgAnnotations: true, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have dashboard and organization scope and all dashboards if FlagAnnotationPermissionUpdate is enabled", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeOrganization, dashboards.ScopeDashboardsAll}, - }, - featureToggle: featuremgmt.FlagAnnotationPermissionUpdate, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID, dash2.UID: dash2.ID}, - CanAccessOrgAnnotations: true, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have dashboard and organization scope and all dashboards if FlagAnnotationPermissionUpdate is enabled and folder based scope is used", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeOrganization, dashboards.ScopeFoldersAll}, - }, - featureToggle: featuremgmt.FlagAnnotationPermissionUpdate, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID, dash2.UID: dash2.ID}, - CanAccessOrgAnnotations: true, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have only organization scope and no dashboards", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeOrganization}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedResources: &AccessResources{ - Dashboards: nil, - CanAccessOrgAnnotations: true, - }, - }, - { - name: "should have only dashboard scope and all dashboards", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID, dash2.UID: dash2.ID}, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have only dashboard scope and all dashboards if FlagAnnotationPermissionUpdate is enabled", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {dashboards.ScopeDashboardsAll}, - }, - featureToggle: featuremgmt.FlagAnnotationPermissionUpdate, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID, dash2.UID: dash2.ID}, - CanAccessOrgAnnotations: false, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have only dashboard scope and only dashboard 1", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - dashboards.ActionDashboardsRead: {fmt.Sprintf("dashboards:uid:%s", dash1.UID)}, - }, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID}, - CanAccessDashAnnotations: true, - }, - }, - { - name: "should have only dashboard scope and only dashboard 1 if FlagAnnotationPermissionUpdate is enabled", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash1.UID)}, - }, - featureToggle: featuremgmt.FlagAnnotationPermissionUpdate, - expectedResources: &AccessResources{ - Dashboards: map[string]int64{dash1.UID: dash1.ID}, - CanAccessOrgAnnotations: false, - CanAccessDashAnnotations: true, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - u.Permissions = map[int64]map[string][]string{1: tc.permissions} - testutil.SetupRBACPermission(t, sql, role, u) - authz := NewAuthService(sql, featuremgmt.WithFeatures(tc.featureToggle), dashSvc) - - query := annotations.ItemQuery{SignedInUser: u, OrgID: 1} - resources, err := authz.Authorize(context.Background(), query) - require.NoError(t, err) - - if tc.expectedResources.Dashboards != nil { - require.Equal(t, tc.expectedResources.Dashboards, resources.Dashboards) - } - - require.Equal(t, tc.expectedResources.CanAccessDashAnnotations, resources.CanAccessDashAnnotations) - require.Equal(t, tc.expectedResources.CanAccessOrgAnnotations, resources.CanAccessOrgAnnotations) - - if tc.expectedErr != nil { - require.Equal(t, tc.expectedErr, err) - } - }) - } -} diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go deleted file mode 100644 index 9cb5a099812..00000000000 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ /dev/null @@ -1,368 +0,0 @@ -package annotationsimpl - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/annotations" - "github.com/grafana/grafana/pkg/services/annotations/testutil" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" - dashboardsservice "github.com/grafana/grafana/pkg/services/dashboards/service" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - alertingStore "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/tests/testsuite" -) - -func TestMain(m *testing.M) { - testsuite.Run(m) -} - -func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - sql := db.InitTestDB(t) - - cfg := setting.NewCfg() - cfg.AnnotationMaximumTagsLength = 60 - - features := featuremgmt.WithFeatures() - tagService := tagimpl.ProvideService(sql) - ruleStore := alertingStore.SetupStoreForTesting(t, sql) - folderStore := folderimpl.ProvideDashboardFolderStore(sql) - fStore := folderimpl.ProvideStore(sql) - dashStore, err := database.ProvideDashboardStore(sql, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql)) - require.NoError(t, err) - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, - nil, sql, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), - ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(sql, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore()) - require.NoError(t, err) - dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService()) - repo := ProvideService(sql, cfg, features, tagService, tracing.InitializeTracerForTest(), ruleStore, dashSvc, prometheus.NewPedanticRegistry()) - - dashboard1 := testutil.CreateDashboard(t, sql, cfg, features, dashboards.SaveDashboardCommand{ - UserID: 1, - OrgID: 1, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dashboard 1", - }), - }) - - dashboard2 := testutil.CreateDashboard(t, sql, cfg, features, dashboards.SaveDashboardCommand{ - UserID: 1, - OrgID: 1, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dashboard 2", - }), - }) - - dash1Annotation := &annotations.Item{ - OrgID: 1, - DashboardID: 1, // nolint: staticcheck - DashboardUID: dashboard1.UID, - Epoch: 10, - } - err = repo.Save(context.Background(), dash1Annotation) - require.NoError(t, err) - - dash2Annotation := &annotations.Item{ - OrgID: 1, - DashboardID: 2, // nolint: staticcheck - DashboardUID: dashboard2.UID, - Epoch: 10, - Tags: []string{"foo:bar"}, - } - err = repo.Save(context.Background(), dash2Annotation) - require.NoError(t, err) - - organizationAnnotation := &annotations.Item{ - OrgID: 1, - Epoch: 10, - } - err = repo.Save(context.Background(), organizationAnnotation) - require.NoError(t, err) - - u := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - } - role := testutil.SetupRBACRole(t, sql, u) - - type testStruct struct { - description string - permissions map[string][]string - expectedAnnotationIds []int64 - expectedError bool - } - - testCases := []testStruct{ - { - description: "Should find all annotations when has permissions to list all annotations and read all dashboards", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsAll}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedAnnotationIds: []int64{dash1Annotation.ID, dash2Annotation.ID, organizationAnnotation.ID}, - }, - { - description: "Should find all dashboard annotations", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedAnnotationIds: []int64{dash1Annotation.ID, dash2Annotation.ID}, - }, - { - description: "Should find only annotations from dashboards that user can read", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - dashboards.ActionDashboardsRead: {fmt.Sprintf("dashboards:uid:%s", dashboard1.UID)}, - }, - expectedAnnotationIds: []int64{dash1Annotation.ID}, - }, - { - description: "Should find no annotations if user can't view dashboards or organization annotations", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - }, - expectedAnnotationIds: []int64{}, - }, - { - description: "Should find only organization annotations", - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeOrganization}, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedAnnotationIds: []int64{organizationAnnotation.ID}, - }, - { - description: "Should error if user doesn't have annotation read permissions", - permissions: map[string][]string{ - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, - }, - expectedError: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - u.Permissions = map[int64]map[string][]string{1: tc.permissions} - testutil.SetupRBACPermission(t, sql, role, u) - - results, err := repo.Find(context.Background(), &annotations.ItemQuery{ - OrgID: 1, - SignedInUser: u, - }) - if tc.expectedError { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Len(t, results, len(tc.expectedAnnotationIds)) - for _, r := range results { - assert.Contains(t, tc.expectedAnnotationIds, r.ID) - } - }) - } -} - -func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - orgID := int64(1) - permissions := []accesscontrol.Permission{ - { - Action: dashboards.ActionFoldersCreate, - Scope: dashboards.ScopeFoldersAll, - }, - } - usr := &user.SignedInUser{ - UserID: 1, - OrgID: orgID, - Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByActionContext(context.Background(), permissions)}, - } - - var role *accesscontrol.Role - - type dashInfo struct { - UID string - ID int64 - } - - allDashboards := make([]dashInfo, 0, folder.MaxNestedFolderDepth+1) - annotationsTexts := make([]string, 0, folder.MaxNestedFolderDepth+1) - - setupFolderStructure := func() (db.DB, dashboards.DashboardService) { - sql, cfg := db.InitTestDBWithCfg(t) - - // enable nested folders so that the folder table is populated for all the tests - features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) - - tagService := tagimpl.ProvideService(sql) - - dashStore, err := database.ProvideDashboardStore(sql, cfg, features, tagService) - require.NoError(t, err) - - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - fStore := folderimpl.ProvideStore(sql) - folderStore := folderimpl.ProvideDashboardFolderStore(sql) - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, - nil, sql, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, features, accesscontrolmock.NewMockedPermissionsService(), - ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(sql, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService()) - cfg.AnnotationMaximumTagsLength = 60 - - store := NewXormStore(cfg, log.New("annotation.test"), sql, tagService) - - parentUID := "" - for i := 0; ; i++ { - uid := fmt.Sprintf("f%d", i) - f, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: uid, - OrgID: orgID, - Title: uid, - SignedInUser: usr, - ParentUID: parentUID, - }) - if err != nil { - if errors.Is(err, folder.ErrMaximumDepthReached) { - break - } - - t.Log("unexpected error", "error", err) - t.Fail() - } - - dashboard, err := dashSvc.SaveDashboard(context.Background(), &dashboards.SaveDashboardDTO{ - User: usr, - OrgID: orgID, - Dashboard: &dashboards.Dashboard{ - IsFolder: false, - Title: fmt.Sprintf("Dashboard under %s", f.UID), - Data: simplejson.New(), - FolderID: f.ID, // nolint:staticcheck - FolderUID: f.UID, - }, - }, false) - require.NoError(t, err) - - allDashboards = append(allDashboards, dashInfo{UID: dashboard.UID, ID: dashboard.ID}) - - parentUID = f.UID - - annotationTxt := fmt.Sprintf("annotation %d", i) - dash1Annotation := &annotations.Item{ - OrgID: orgID, - DashboardID: dashboard.ID, // nolint: staticcheck - DashboardUID: dashboard.UID, - Epoch: 10, - Text: annotationTxt, - } - err = store.Add(context.Background(), dash1Annotation) - require.NoError(t, err) - - annotationsTexts = append(annotationsTexts, annotationTxt) - } - - role = testutil.SetupRBACRole(t, sql, usr) - return sql, dashSvc - } - - sql, dashSvc := setupFolderStructure() - - testCases := []struct { - desc string - features featuremgmt.FeatureToggles - permissions map[string][]string - expectedAnnotationText []string - expectedError bool - }{ - { - desc: "Should find only annotations from dashboards under folders that user can read", - features: featuremgmt.WithFeatures(), - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - dashboards.ActionDashboardsRead: {"folders:uid:f0"}, - }, - expectedAnnotationText: annotationsTexts[:1], - }, - { - desc: "Should find only annotations from dashboards under inherited folders if nested folder are enabled", - features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), - permissions: map[string][]string{ - accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, - dashboards.ActionDashboardsRead: {"folders:uid:f0"}, - }, - expectedAnnotationText: annotationsTexts[:], - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - cfg := setting.NewCfg() - cfg.AnnotationMaximumTagsLength = 60 - ruleStore := alertingStore.SetupStoreForTesting(t, sql) - repo := ProvideService(sql, cfg, tc.features, tagimpl.ProvideService(sql), tracing.InitializeTracerForTest(), ruleStore, dashSvc, prometheus.NewPedanticRegistry()) - - usr.Permissions = map[int64]map[string][]string{1: tc.permissions} - testutil.SetupRBACPermission(t, sql, role, usr) - - results, err := repo.Find(context.Background(), &annotations.ItemQuery{ - OrgID: 1, - SignedInUser: usr, - }) - if tc.expectedError { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Len(t, results, len(tc.expectedAnnotationText)) - for _, r := range results { - assert.Contains(t, tc.expectedAnnotationText, r.Text) - } - }) - } -} diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 015c42a5075..a24365d031f 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -23,8 +23,13 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationAnnotations(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/tests/api/annotations/annotations_test.go b/pkg/tests/api/annotations/annotations_test.go new file mode 100644 index 00000000000..18b4e28f55c --- /dev/null +++ b/pkg/tests/api/annotations/annotations_test.go @@ -0,0 +1,439 @@ +package annotations + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/api/dtos" + + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestIntegrationAnnotations(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{featuremgmt.FlagAnnotationPermissionUpdate}, + }) + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + noneUserID := tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleNone), + Login: "noneuser", + Password: "noneuser", + IsAdmin: false, + OrgID: 1, + }) + + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Login: "editor", + Password: "editor", + IsAdmin: false, + OrgID: 1, + }) + + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + Login: "viewer", + Password: "viewer", + IsAdmin: false, + OrgID: 1, + }) + savedFolder := createFolder(t, grafanaListedAddr, "Test Folder") + dash1 := createDashboard(t, grafanaListedAddr, "Dashboard 1", savedFolder.ID, savedFolder.UID) // nolint:staticcheck + dash2 := createDashboard(t, grafanaListedAddr, "Dashboard 2", savedFolder.ID, savedFolder.UID) // nolint:staticcheck + createAnnotation(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboardId": dash1.ID, + "panelId": 1, + "text": "Dashboard 1 annotation", + "time": 1234567890000, + }) + + createAnnotation(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboardId": dash2.ID, + "panelId": 1, + "text": "Dashboard 2 annotation", + "time": 1234567890000, + }) + + createAnnotation(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "text": "Organization annotation", + "time": 1234567890000, + }) + + t.Run("basic tests", func(t *testing.T) { + t.Run("should allow accessing annotations for specific dashboard", func(t *testing.T) { + url := fmt.Sprintf("http://admin:admin@%s/api/annotations?dashboardId=%d", grafanaListedAddr, dash1.ID) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + err = resp.Body.Close() + require.NoError(t, err) + + var annotations []interface{} + err = json.Unmarshal(body, &annotations) + require.NoError(t, err) + assert.Len(t, annotations, 1) + }) + + t.Run("should allow accessing annotations for specific dashboard by UID", func(t *testing.T) { + url := fmt.Sprintf("http://admin:admin@%s/api/annotations?dashboardUID=%s", grafanaListedAddr, dash1.UID) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + err = resp.Body.Close() + require.NoError(t, err) + + var annotations []interface{} + err = json.Unmarshal(body, &annotations) + require.NoError(t, err) + assert.Len(t, annotations, 1) + }) + }) + + t.Run("access control tests", func(t *testing.T) { + viewPermissions := []map[string]interface{}{ + { + "permission": 1, + "userId": noneUserID, + }, + } + + t.Run("should have no dashboards if missing annotation read permission on dashboards", func(t *testing.T) { + url := fmt.Sprintf("http://noneuser:noneuser@%s/api/annotations", grafanaListedAddr) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, resp.StatusCode, http.StatusForbidden) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("should be able to see annotations for dashboards that user has access to", func(t *testing.T) { + setDashboardPermissions(t, grafanaListedAddr, dash1.UID, viewPermissions) + + // should be able to get first one + url := fmt.Sprintf("http://noneuser:noneuser@%s/api/annotations?dashboardId=%d", grafanaListedAddr, dash1.ID) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + + // cannot get the second one + url = fmt.Sprintf("http://noneuser:noneuser@%s/api/annotations?dashboardId=%d", grafanaListedAddr, dash2.ID) + resp, err = http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("should inherit folder permissions", func(t *testing.T) { + setFolderPermissions(t, grafanaListedAddr, savedFolder.UID, viewPermissions) + + url := fmt.Sprintf("http://noneuser:noneuser@%s/api/annotations", grafanaListedAddr) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + err = resp.Body.Close() + require.NoError(t, err) + + var annotations []interface{} + err = json.Unmarshal(body, &annotations) + require.NoError(t, err) + assert.Len(t, annotations, 2) + }) + + t.Run("should allow admin to access all annotations", func(t *testing.T) { + url := fmt.Sprintf("http://admin:admin@%s/api/annotations", grafanaListedAddr) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + err = resp.Body.Close() + require.NoError(t, err) + + var annotations []interface{} + err = json.Unmarshal(body, &annotations) + require.NoError(t, err) + assert.Len(t, annotations, 3) + }) + + dash3 := createDashboard(t, grafanaListedAddr, "Dashboard 3", 0, "") + createAnnotation(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboardId": dash3.ID, + "panelId": 1, + "text": "Dashboard 3 annotation", + "time": 1234567890000, + }) + + t.Run("should allow editor to access org annotations and annotations for dashboards they have access to (dash3)", func(t *testing.T) { + url := fmt.Sprintf("http://editor:editor@%s/api/annotations", grafanaListedAddr) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + err = resp.Body.Close() + require.NoError(t, err) + + var annotations []interface{} + err = json.Unmarshal(body, &annotations) + require.NoError(t, err) + assert.Len(t, annotations, 2) + }) + + t.Run("should allow viewer to access org annotations and annotations for dashboards they have access to (dash3)", func(t *testing.T) { + url := fmt.Sprintf("http://viewer:viewer@%s/api/annotations", grafanaListedAddr) + resp, err := http.Get(url) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + err = resp.Body.Close() + require.NoError(t, err) + + var annotations []interface{} + err = json.Unmarshal(body, &annotations) + require.NoError(t, err) + assert.Len(t, annotations, 2) + }) + + t.Run("should allow editor to create org annotations", func(t *testing.T) { + annotationPayload := map[string]interface{}{ + "text": "Test annotations", + "time": 1234567890000, + } + + payloadBytes, err := json.Marshal(annotationPayload) + require.NoError(t, err) + url := fmt.Sprintf("http://editor:editor@%s/api/annotations", grafanaListedAddr) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("should deny viewer from creating org annotations", func(t *testing.T) { + annotationPayload := map[string]interface{}{ + "text": "Test annotation", + "time": 1234567890000, + } + + payloadBytes, err := json.Marshal(annotationPayload) + require.NoError(t, err) + + url := fmt.Sprintf("http://viewer:viewer@%s/api/annotations", grafanaListedAddr) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("should allow editor to create dashboard annotations", func(t *testing.T) { + annotationPayload := map[string]interface{}{ + "dashboardId": dash3.ID, + "panelId": 1, + "text": "Test annotations", + "time": 1234567890000, + } + + payloadBytes, err := json.Marshal(annotationPayload) + require.NoError(t, err) + url := fmt.Sprintf("http://editor:editor@%s/api/annotations", grafanaListedAddr) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("should deny viewer from creating dashboard annotations", func(t *testing.T) { + annotationPayload := map[string]interface{}{ + "dashboardId": dash3.ID, + "panelId": 1, + "text": "Test annotation", + "time": 1234567890000, + } + + payloadBytes, err := json.Marshal(annotationPayload) + require.NoError(t, err) + + url := fmt.Sprintf("http://viewer:viewer@%s/api/annotations", grafanaListedAddr) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + }) +} + +func createAnnotation(t *testing.T, grafanaListedAddr string, username, password string, payload map[string]interface{}) { + t.Helper() + + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + url := fmt.Sprintf("http://%s:%s@%s/api/annotations", username, password, grafanaListedAddr) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} + +func setDashboardPermissions(t *testing.T, grafanaListedAddr string, dashboardUID string, permissions []map[string]interface{}) { + t.Helper() + + payload := map[string]interface{}{ + "items": permissions, + } + + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + url := fmt.Sprintf("http://admin:admin@%s/api/dashboards/uid/%s/permissions", grafanaListedAddr, dashboardUID) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} + +func setFolderPermissions(t *testing.T, grafanaListedAddr string, folderUID string, permissions []map[string]interface{}) { + t.Helper() + + payload := map[string]interface{}{ + "items": permissions, + } + + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + url := fmt.Sprintf("http://admin:admin@%s/api/folders/%s/permissions", grafanaListedAddr, folderUID) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payloadBytes)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) +} + +func createFolder(t *testing.T, grafanaListedAddr string, title string) *dtos.Folder { + t.Helper() + + buf1 := &bytes.Buffer{} + err := json.NewEncoder(buf1).Encode(folder.CreateFolderCommand{ + Title: title, + }) + require.NoError(t, err) + u := fmt.Sprintf("http://admin:admin@%s/api/folders", grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", buf1) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + var f *dtos.Folder + err = json.Unmarshal(b, &f) + require.NoError(t, err) + + return f +} + +func createDashboard(t *testing.T, grafanaListedAddr string, title string, folderID int64, folderUID string) *dashboards.Dashboard { + t.Helper() + + buf := &bytes.Buffer{} + err := json.NewEncoder(buf).Encode(map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": title, + }, + "folderId": folderID, + "folderUid": folderUID, + "overwrite": true, + }) + require.NoError(t, err) + + u := fmt.Sprintf("http://admin:admin@%s/api/dashboards/db", grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", buf) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var saveResp struct { + Status string `json:"status"` + Slug string `json:"slug"` + Version int64 `json:"version"` + ID int64 `json:"id"` + UID string `json:"uid"` + URL string `json:"url"` + FolderUID string `json:"folderUid"` + } + err = json.Unmarshal(b, &saveResp) + require.NoError(t, err) + require.NotEmpty(t, saveResp.UID) + + return &dashboards.Dashboard{ + ID: saveResp.ID, // nolint:staticcheck + UID: saveResp.UID, + Slug: saveResp.Slug, + Version: int(saveResp.Version), + FolderUID: saveResp.FolderUID, + } +} From 5ef744aa20728d2fe225c2011e98cb6484e1946e Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 28 Jul 2025 11:38:10 -0500 Subject: [PATCH 18/39] Library panels: Move to integration tests (#108737) --- .../libraryelements/libraryelements_test.go | 74 --------- .../library_panels_api_validation_test.go | 143 ++++++++++++++++++ 2 files changed, 143 insertions(+), 74 deletions(-) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index ad8b412e860..def4ff5660a 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -216,80 +216,6 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) { } }) - scenarioWithPanel(t, "When a user tries to get connections of library panel, dashboards in inaccessible folders should not be returned", - func(t *testing.T, sc scenarioContext) { - accessibleFolder := createFolder(t, sc, "AccessibleFolder", sc.service.folderService) - inaccessibleFolder := createFolder(t, sc, "InAccessibleFolder", sc.service.folderService) - restrictedUser := user.SignedInUser{ - UserID: 2, - Name: "Non-Admin User", - Login: "non-admin-user", - OrgID: sc.user.OrgID, - OrgRole: org.RoleViewer, - LastSeenAt: time.Now(), - Permissions: map[int64]map[string][]string{ - sc.user.OrgID: { - dashboards.ActionFoldersRead: { - dashboards.ScopeFoldersProvider.GetResourceScopeUID(accessibleFolder.UID), - }, - dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID("*")}, - }, - }, - } - - command := getCreatePanelCommand(accessibleFolder.ID, accessibleFolder.UID, "Accessible Library Panel") // nolint:staticcheck - sc.reqContext.Req.Body = mockRequestBody(command) - resp := sc.service.createHandler(sc.reqContext) - libraryElement := validateAndUnMarshalResponse(t, resp) - - dashJSON := map[string]any{ - "panels": []any{ - map[string]any{ - "id": int64(1), - "gridPos": map[string]any{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - "libraryPanel": map[string]any{ - "uid": libraryElement.Result.UID, - "name": libraryElement.Result.Name, - }, - }, - }, - } - accessibleDash := dashboards.Dashboard{ - Title: "Accessible Dashboard", - Data: simplejson.NewFromAny(dashJSON), - } - - // create the dashboard in the general folder, an accessible folder, and an inaccessible folder - dashInGeneral := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, "") - err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInGeneral.ID) - require.NoError(t, err) - - dashInAccessibleFolder := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, accessibleFolder.UID) - err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInAccessibleFolder.ID) - require.NoError(t, err) - - dashInInaccessibleFolder := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, inaccessibleFolder.UID) - err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInInaccessibleFolder.ID) - require.NoError(t, err) - - sc.reqContext.SignedInUser = &restrictedUser - sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": libraryElement.Result.UID}) - - // connections should return the general folder one and the accessible folder one - connectionsResp := sc.service.getConnectionsHandler(sc.reqContext) - var result = validateAndUnMarshalConnectionResponse(t, connectionsResp) - require.Len(t, result.Result, 2) - uids := []string{result.Result[0].ConnectionUID, result.Result[1].ConnectionUID} - require.Contains(t, uids, dashInGeneral.UID) - require.Contains(t, uids, dashInAccessibleFolder.UID) - require.NotContains(t, uids, dashInInaccessibleFolder.UID) - }) - scenarioWithPanel(t, "When an admin tries to create a connection with an element that exists, but the original folder does not, it should still succeed", func(t *testing.T, sc scenarioContext) { b, err := json.Marshal(map[string]string{"test": "test"}) diff --git a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go index c9d74b964df..b2857d97a69 100644 --- a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go @@ -12,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" ) @@ -292,3 +293,145 @@ func deleteLibraryElement(t *testing.T, ctx TestContext, user apis.User, uid str return nil } + +func TestIntegrationLibraryPanelConnectionsWithFolderAccess(t *testing.T) { + dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, dualWriterMode := range dualWriterModes { + t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{ + "unifiedStorageSearch", + "kubernetesLibraryPanels", + "kubernetesClientDashboardsFolders", + }, + }) + ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) + + accessibleFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "AccessibleFolder") + require.NoError(t, err) + require.NotNil(t, accessibleFolder) + + inaccessibleFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "InAccessibleFolder") + require.NoError(t, err) + require.NotNil(t, inaccessibleFolder) + + setResourceUserPermission(t, ctx, ctx.AdminUser, false, accessibleFolder.UID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelView)) + setResourceUserPermission(t, ctx, ctx.AdminUser, false, inaccessibleFolder.UID, []ResourcePermissionSetting{}) + + libraryElement := map[string]interface{}{ + "kind": 1, + "name": "Accessible Library Panel", + "folderUid": accessibleFolder.UID, + "model": map[string]interface{}{ + "type": "text", + "title": "Accessible Library Panel", + }, + } + libraryElementURL := "/api/library-elements" + libraryElementData, err := postHelper(t, &ctx, libraryElementURL, libraryElement, ctx.AdminUser) + require.NoError(t, err) + require.NotNil(t, libraryElementData) + data := libraryElementData["result"].(map[string]interface{}) + uid := data["uid"].(string) + require.NotEmpty(t, uid) + + dashInGeneral := createDashboardObject(t, "Dashboard in General", "", 1) + dashInGeneral.Object["spec"].(map[string]interface{})["panels"] = []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "Library Panel", + "type": "library-panel-ref", + "libraryPanel": map[string]interface{}{ + "uid": uid, + "name": "Accessible Library Panel", + }, + }, + } + adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) + createdDashInGeneral, err := adminClient.Resource.Create(context.Background(), dashInGeneral, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDashInGeneral) + + dashInAccessibleFolder := createDashboardObject(t, "Dashboard in Accessible Folder", accessibleFolder.UID, 1) + dashInAccessibleFolder.Object["spec"].(map[string]interface{})["panels"] = []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "Library Panel", + "type": "library-panel-ref", + "libraryPanel": map[string]interface{}{ + "uid": uid, + "name": "Accessible Library Panel", + }, + }, + } + createdDashInAccessible, err := adminClient.Resource.Create(context.Background(), dashInAccessibleFolder, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDashInAccessible) + + dashInInaccessibleFolder := createDashboardObject(t, "Dashboard in Inaccessible Folder", inaccessibleFolder.UID, 1) + dashInInaccessibleFolder.Object["spec"].(map[string]interface{})["panels"] = []interface{}{ + map[string]interface{}{ + "id": 1, + "title": "Library Panel", + "type": "library-panel-ref", + "libraryPanel": map[string]interface{}{ + "uid": uid, + "name": "Accessible Library Panel", + }, + }, + } + createdDashInInaccessible, err := adminClient.Resource.Create(context.Background(), dashInInaccessibleFolder, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDashInInaccessible) + + connectionsURL := fmt.Sprintf("/api/library-elements/%s/connections", uid) + connectionsData, err := getDashboardViaHTTP(t, &ctx, connectionsURL, ctx.AdminUser) + require.NoError(t, err) + require.NotNil(t, connectionsData) + connections := connectionsData["result"].([]interface{}) + require.Len(t, connections, 3, "Admin should see all connections") + connectionUIDs := make([]string, 0, len(connections)) + for _, conn := range connections { + connMap := conn.(map[string]interface{}) + if connectionUID, ok := connMap["connectionUid"].(string); ok { + connectionUIDs = append(connectionUIDs, connectionUID) + } + } + generalDashUID := createdDashInGeneral.GetName() + accessibleDashUID := createdDashInAccessible.GetName() + inaccessibleDashUID := createdDashInInaccessible.GetName() + require.Contains(t, connectionUIDs, generalDashUID, "Admin should see dashboard in general folder") + require.Contains(t, connectionUIDs, accessibleDashUID, "Admin should see dashboard in accessible folder") + require.Contains(t, connectionUIDs, inaccessibleDashUID, "Admin should see dashboard in inaccessible folder") + + limitedUser := ctx.Helper.CreateUser("limited-user", "Org1", org.RoleViewer, nil) + // can access accessibleFolder but not inaccessibleFolder + setResourceUserPermission(t, ctx, ctx.AdminUser, false, accessibleFolder.UID, addUserPermission(t, nil, limitedUser, ResourcePermissionLevelView)) + setResourceUserPermission(t, ctx, ctx.AdminUser, false, inaccessibleFolder.UID, []ResourcePermissionSetting{}) + connectionsDataLimited, err := getDashboardViaHTTP(t, &ctx, connectionsURL, limitedUser) + require.NoError(t, err) + require.NotNil(t, connectionsDataLimited) + connectionsLimited := connectionsDataLimited["result"].([]interface{}) + require.Len(t, connectionsLimited, 2, "Limited user should only see connections to accessible dashboards") + + connectionUIDsLimited := make([]string, 0, len(connectionsLimited)) + for _, conn := range connectionsLimited { + connMap := conn.(map[string]interface{}) + if connectionUID, ok := connMap["connectionUid"].(string); ok { + connectionUIDsLimited = append(connectionUIDsLimited, connectionUID) + } + } + require.Contains(t, connectionUIDsLimited, generalDashUID, "Limited user should see dashboard in general folder") + require.Contains(t, connectionUIDsLimited, accessibleDashUID, "Limited user should see dashboard in accessible folder") + require.NotContains(t, connectionUIDsLimited, inaccessibleDashUID, "Limited user should NOT see dashboard in inaccessible folder") + + err = adminClient.Resource.Delete(context.Background(), createdDashInGeneral.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + err = adminClient.Resource.Delete(context.Background(), createdDashInAccessible.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + err = adminClient.Resource.Delete(context.Background(), createdDashInInaccessible.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + } +} From caa75b1d9421ba3e07d5d07618e1f4b81472f200 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 28 Jul 2025 12:14:09 -0500 Subject: [PATCH 19/39] Public dashboards: move to integration tests (#108735) --- .../publicdashboards/api/common_test.go | 92 --- .../publicdashboards/api/query_test.go | 147 ----- .../publicdashboards/service/service_test.go | 559 ------------------ .../public_dashboard_query_test.go | 195 ++++++ .../public_dashboards_api_test.go | 439 ++++++++++++++ 5 files changed, 634 insertions(+), 798 deletions(-) create mode 100644 pkg/tests/api/publicdashboards/public_dashboard_query_test.go create mode 100644 pkg/tests/api/publicdashboards/public_dashboards_api_test.go diff --git a/pkg/services/publicdashboards/api/common_test.go b/pkg/services/publicdashboards/api/common_test.go index d022408d82f..c7847135380 100644 --- a/pkg/services/publicdashboards/api/common_test.go +++ b/pkg/services/publicdashboards/api/common_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "io" "net/http" "net/http/httptest" @@ -9,32 +8,15 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/api/routing" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/datasources" - fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" - "github.com/grafana/grafana/pkg/services/datasources/guardian" - datasourceService "github.com/grafana/grafana/pkg/services/datasources/service" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing/licensingtest" - "github.com/grafana/grafana/pkg/services/mtdsclient" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" - "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" - pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/publicdashboards" publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models" - "github.com/grafana/grafana/pkg/services/query" - fakeSecrets "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -108,77 +90,3 @@ func callAPI(server *web.Mux, method, path string, body io.Reader, t *testing.T) server.ServeHTTP(recorder, req) return recorder } - -// helper to query.Service -// allows us to stub the cache and plugin clients -func buildQueryDataService(t *testing.T, cs datasources.CacheService, fpc *fakePluginClient, store db.DB) *query.ServiceImpl { - // build database if we need one - if store == nil { - store = db.InitTestDB(t) - } - - // default cache service - if cs == nil { - cs = datasourceService.ProvideCacheService(localcache.ProvideService(), store, guardian.ProvideGuardian()) - } - - // default fakePluginClient - if fpc == nil { - fpc = &fakePluginClient{ - QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - resp := backend.Responses{ - "A": backend.DataResponse{ - Frames: []*data.Frame{{}}, - }, - } - return &backend.QueryDataResponse{Responses: resp}, nil - }, - } - } - - ds := &fakeDatasources.FakeDataSourceService{} - pCtxProvider := plugincontext.ProvideService(setting.NewCfg(), - localcache.ProvideService(), &pluginstore.FakePluginStore{ - PluginList: []pluginstore.Plugin{ - { - JSONData: plugins.JSONData{ - ID: "mysql", - }, - }, - }, - }, &fakeDatasources.FakeCacheService{}, ds, - pluginSettings.ProvideService(store, fakeSecrets.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider()) - - return query.ProvideService( - setting.NewCfg(), - cs, - nil, - &fakeDataSourceRequestValidator{}, - fpc, - pCtxProvider, - mtdsclient.NewNullMTDatasourceClientBuilder(), - ) -} - -// copied from pkg/api/metrics_test.go -type fakeDataSourceRequestValidator struct { - err error -} - -func (rv *fakeDataSourceRequestValidator) Validate(ds *datasources.DataSource, req *http.Request) error { - return rv.err -} - -// copied from pkg/api/plugins_test.go -type fakePluginClient struct { - plugins.Client - backend.QueryDataHandlerFunc -} - -func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - if c.QueryDataHandlerFunc != nil { - return c.QueryDataHandlerFunc.QueryData(ctx, req) - } - - return backend.NewQueryDataResponse(), nil -} diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index c3b508d5627..02367824fbd 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "errors" "fmt" @@ -20,35 +19,9 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/annotations/annotationstest" - "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" - dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" - "github.com/grafana/grafana/pkg/services/dashboards/service" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/datasources/guardian" - datasourcesService "github.com/grafana/grafana/pkg/services/datasources/service" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/licensing/licensingtest" "github.com/grafana/grafana/pkg/services/publicdashboards" - publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" - publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/web" ) @@ -258,126 +231,6 @@ func getValidQueryPath(accessToken string) string { return fmt.Sprintf("/api/public/dashboards/%s/panels/2/query", accessToken) } -func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - db, cfg := db.InitTestDBWithCfg(t) - - cacheService := datasourcesService.ProvideCacheService(localcache.ProvideService(), db, guardian.ProvideGuardian()) - qds := buildQueryDataService(t, cacheService, nil, db) - dsStore := datasourcesService.CreateStore(db, log.New("publicdashboards.test")) - _, _ = dsStore.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ - UID: "ds1", - OrgID: 1, - Name: "laban", - Type: datasources.DS_MYSQL, - Access: datasources.DS_ACCESS_DIRECT, - URL: "http://test", - Database: "site", - ReadOnly: true, - }) - - // Create Dashboard - saveDashboardCmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - FolderUID: "", - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": "test", - "panels": []map[string]any{ - { - "id": 1, - "targets": []map[string]any{ - { - "datasource": map[string]string{ - "type": "mysql", - "uid": "ds1", - }, - "refId": "A", - }, - }, - }, - }, - }), - } - - // create dashboard - dashboardStoreService, err := dashboardStore.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db)) - require.NoError(t, err) - dashboard, err := dashboardStoreService.SaveDashboard(context.Background(), saveDashboardCmd) - require.NoError(t, err) - - // Create public dashboard - isEnabled := true - savePubDashboardCmd := &SavePublicDashboardDTO{ - DashboardUid: dashboard.UID, - OrgID: dashboard.OrgID, - PublicDashboard: &PublicDashboardDTO{ - IsEnabled: &isEnabled, - }, - } - - annotationsService := annotationstest.NewFakeAnnotationsRepo() - - // create public dashboard - store := publicdashboardsStore.ProvideStore(db, cfg, featuremgmt.WithFeatures()) - cfg.PublicDashboardsEnabled = true - ac := actest.FakeAccessControl{} - ws := publicdashboardsService.ProvideServiceWrapper(store) - folderStore := folderimpl.ProvideDashboardFolderStore(db) - dashPermissionService := acmock.NewMockedPermissionsService() - dashService, err := service.ProvideDashboardServiceImpl( - cfg, dashboardStoreService, folderStore, - featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), ac, actest.FakeService{}, - foldertest.NewFakeService(), nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, - nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(db, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - dashService.RegisterDashboardPermissions(dashPermissionService) - - license := licensingtest.NewFakeLicensing() - license.On("FeatureEnabled", FeaturePublicDashboardsEmailSharing).Return(false) - pds := publicdashboardsService.ProvideService(cfg, featuremgmt.WithFeatures(), store, qds, annotationsService, ac, ws, dashService, license) - pubdash, err := pds.Create(context.Background(), &user.SignedInUser{}, savePubDashboardCmd) - require.NoError(t, err) - - // setup test server - server := setupTestServer(t, cfg, pds, anonymousUser) - - resp := callAPI(server, http.MethodPost, - fmt.Sprintf("/api/public/dashboards/%s/panels/1/query", pubdash.AccessToken), - strings.NewReader(`{}`), - t, - ) - require.Equal(t, http.StatusOK, resp.Code) - require.NoError(t, err) - require.JSONEq( - t, - `{ - "results": { - "A": { - "status": 200, - "frames": [ - { - "data": { - "values": [] - }, - "schema": { - "fields": [] - } - } - ] - } - } - }`, - resp.Body.String(), - ) -} - func TestAPIGetAnnotations(t *testing.T) { testCases := []struct { Name string diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index f79ada33f83..d10eb8641dc 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -16,33 +16,16 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/errutil" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" "github.com/grafana/grafana/pkg/services/dashboards" dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" - dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/org" . "github.com/grafana/grafana/pkg/services/publicdashboards" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" ) @@ -1407,548 +1390,6 @@ func TestDashboardEnabledChanged(t *testing.T) { }) } -func TestIntegrationPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - features := featuremgmt.WithFeatures() - testDB, cfg := db.InitTestDBWithCfg(t) - dashStore, err := dashboardsDB.ProvideDashboardStore(testDB, cfg, features, tagimpl.ProvideService(testDB)) - require.NoError(t, err) - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - - fStore := folderimpl.ProvideStore(testDB) - folderPermissions := acmock.NewMockedPermissionsService() - folderStore := folderimpl.ProvideDashboardFolderStore(testDB) - folderSvc := folderimpl.ProvideService( - fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, - nil, testDB, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - - dashboardService, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, ac, actest.FakeService{}, folderSvc, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(), - serverlock.ProvideService(testDB, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore()) - require.NoError(t, err) - dashboardService.RegisterDashboardPermissions(&actest.FakePermissionsService{}) - - // insert in test data so we can check that permissions are working properly through the dashboard service - // this will create 4 dashboards and 3 users - // user1 has access to all dashboards ("*") - // user2 has access to solely one dashboard - // user3 has access to all created dashboards through specific permissions - creatingUser := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgRole: org.RoleAdmin, - } - dashboardsToSave := []dashboards.SaveDashboardDTO{ - { - OrgID: 1, - User: creatingUser, - Dashboard: &dashboards.Dashboard{ - OrgID: 1, - UID: "9S6TmO67z", - Title: "test", - Slug: "test", - Data: simplejson.New(), - }, - }, - { - OrgID: 1, - User: creatingUser, - Dashboard: &dashboards.Dashboard{ - OrgID: 1, - UID: "1S6TmO67z", - Title: "my first dashboard", - Slug: "my-first-dashboard", - Data: simplejson.New(), - }, - }, - { - OrgID: 1, - User: creatingUser, - Dashboard: &dashboards.Dashboard{ - OrgID: 1, - UID: "2S6TmO67z", - Title: "my second dashboard", - Slug: "my-second-dashboard", - Data: simplejson.New(), - }, - }, - { - OrgID: 1, - User: creatingUser, - Dashboard: &dashboards.Dashboard{ - OrgID: 1, - UID: "0S6TmO67z", - Title: "my zero dashboard", - Slug: "my-zero-dashboard", - Data: simplejson.New(), - }, - }, - } - for _, dash := range dashboardsToSave { - _, err = dashboardService.SaveDashboard(context.Background(), &dash, true) - require.NoError(t, err) - } - - users := []user.User{ - { - ID: 1, - UID: "user1", - Email: "test1@gmail.com", - Login: "user1", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 2, - UID: "user2", - Login: "user2", - Email: "test2@gmail.com", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 3, - UID: "user3", - Login: "user3", - Email: "test3@gmail.com", - Created: time.Now(), - Updated: time.Now(), - }, - } - roles := []accesscontrol.Role{ - { - ID: 1, - UID: "role1", - Name: "forUser1", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 2, - UID: "role2", - Name: "forUser2", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 3, - UID: "role3", - Name: "forUser3", - Created: time.Now(), - Updated: time.Now(), - }, - } - - userRoles := []accesscontrol.UserRole{ - { - ID: 1, - OrgID: 1, - UserID: 1, - RoleID: 1, - Created: time.Now(), - }, - { - ID: 2, - OrgID: 1, - UserID: 2, - RoleID: 2, - Created: time.Now(), - }, - { - ID: 3, - OrgID: 1, - UserID: 3, - RoleID: 3, - Created: time.Now(), - }, - } - - permissions := []accesscontrol.Permission{ - { - ID: 1, - RoleID: 1, - Action: dashboards.ActionDashboardsRead, - Scope: "*", - Kind: "dashboards", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 2, - RoleID: 2, - Action: dashboards.ActionDashboardsRead, - Scope: "dashboards:uid:1S6TmO67z", - Attribute: "uid", - Identifier: "1S6TmO67z", - Kind: "dashboards", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 3, - RoleID: 3, - Action: dashboards.ActionDashboardsRead, - Scope: "dashboards:uid:0S6TmO67z", - Identifier: "0S6TmO67z", - Attribute: "uid", - Kind: "dashboards", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 4, - RoleID: 3, - Action: dashboards.ActionDashboardsRead, - Scope: "dashboards:uid:1S6TmO67z", - Identifier: "1S6TmO67z", - Kind: "dashboards", - Attribute: "uid", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 5, - RoleID: 3, - Action: dashboards.ActionDashboardsRead, - Scope: "dashboards:uid:2S6TmO67z", - Identifier: "2S6TmO67z", - Kind: "dashboards", - Attribute: "uid", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 6, - RoleID: 3, - Action: dashboards.ActionDashboardsRead, - Scope: "dashboards:uid:9S6TmO67z", - Identifier: "9S6TmO67z", - Kind: "dashboards", - Attribute: "uid", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 7, - RoleID: 1, - Action: dashboards.ActionFoldersRead, - Scope: "*", - Kind: "folders", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 8, - RoleID: 2, - Action: dashboards.ActionFoldersRead, - Scope: "*", - Kind: "folders", - Created: time.Now(), - Updated: time.Now(), - }, - { - ID: 9, - RoleID: 3, - Action: dashboards.ActionFoldersRead, - Scope: "*", - Kind: "folders", - Created: time.Now(), - Updated: time.Now(), - }, - } - - err = testDB.WithDbSession(context.Background(), func(sess *db.Session) error { - if _, err := sess.Insert(users); err != nil { - return err - } - if _, err := sess.Insert(roles); err != nil { - return err - } - - if _, err := sess.Insert(userRoles); err != nil { - return err - } - _, err := sess.Insert(permissions) - return err - }) - require.NoError(t, err) - - type args struct { - ctx context.Context - query *PublicDashboardListQuery - } - type mockResponse struct { - PublicDashboardListResponseWithPagination *PublicDashboardListResponseWithPagination - Err error - DashboardResponse []dashboards.DashboardSearchProjection - DashboardErr error - } - - expectedFinalResponse := []*PublicDashboardListResponse{ - { - Uid: "1GwW7mgVk", - AccessToken: "1b458cb7fe7f42c68712078bcacee6e3", - DashboardUid: "1S6TmO67z", - Title: "my first dashboard", - Slug: "my-first-dashboard", - IsEnabled: true, - }, - { - Uid: "2GwW7mgVk", - AccessToken: "2b458cb7fe7f42c68712078bcacee6e3", - DashboardUid: "2S6TmO67z", - Title: "my second dashboard", - Slug: "my-second-dashboard", - IsEnabled: false, - }, - { - Uid: "0GwW7mgVk", - AccessToken: "0b458cb7fe7f42c68712078bcacee6e3", - DashboardUid: "0S6TmO67z", - Title: "my zero dashboard", - Slug: "my-zero-dashboard", - IsEnabled: true, - }, - { - Uid: "9GwW7mgVk", - AccessToken: "deletedashboardaccesstoken", - DashboardUid: "9S6TmO67z", - Title: "test", - Slug: "test", - IsEnabled: true, - }, - } - mockedStoreResponse := []*PublicDashboardListResponse{ - { - Uid: "0GwW7mgVk", - AccessToken: "0b458cb7fe7f42c68712078bcacee6e3", - DashboardUid: "0S6TmO67z", - IsEnabled: true, - }, - { - Uid: "1GwW7mgVk", - AccessToken: "1b458cb7fe7f42c68712078bcacee6e3", - DashboardUid: "1S6TmO67z", - IsEnabled: true, - }, - { - Uid: "2GwW7mgVk", - AccessToken: "2b458cb7fe7f42c68712078bcacee6e3", - DashboardUid: "2S6TmO67z", - IsEnabled: false, - }, - { - Uid: "9GwW7mgVk", - AccessToken: "deletedashboardaccesstoken", - DashboardUid: "9S6TmO67z", - IsEnabled: true, - }, - } - - testCases := []struct { - name string - args args - want *PublicDashboardListResponseWithPagination - mockResponse *mockResponse - wantErr assert.ErrorAssertionFunc - }{ - { - name: "should return full response when user has access to all dashboards", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, UserID: 1, Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"*"}, "folders:read": {"*"}}}}, - OrgID: 1, - Page: 1, - Limit: 50, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: &PublicDashboardListResponseWithPagination{ - TotalCount: int64(len(mockedStoreResponse)), - PublicDashboards: mockedStoreResponse, - }, - Err: nil, - }, - want: &PublicDashboardListResponseWithPagination{ - Page: 1, - PerPage: 50, - TotalCount: int64(len(expectedFinalResponse)), - PublicDashboards: expectedFinalResponse, - }, - wantErr: assert.NoError, - }, - { - name: "should only return the one dashboard user 2 has access to", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, UserID: 2, Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"dashboards:uid:1S6TmO67z"}, "folders:read": {"*"}}}}, - OrgID: 1, - Page: 1, - Limit: 50, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: &PublicDashboardListResponseWithPagination{ - TotalCount: int64(len(mockedStoreResponse)), - PublicDashboards: mockedStoreResponse, - }, - Err: nil, - }, - want: &PublicDashboardListResponseWithPagination{ - Page: 1, - PerPage: 50, - TotalCount: 1, - PublicDashboards: []*PublicDashboardListResponse{expectedFinalResponse[0]}, - }, - wantErr: assert.NoError, - }, - { - name: "should return full response when user 3 has specific access to all dashboards", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, UserID: 3, Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"dashboards:uid:0S6TmO67z", "dashboards:uid:1S6TmO67z", "dashboards:uid:2S6TmO67z", "dashboards:uid:9S6TmO67z"}, "folders:read": {"*"}}}}, - OrgID: 1, - Page: 1, - Limit: 50, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: &PublicDashboardListResponseWithPagination{ - TotalCount: int64(len(mockedStoreResponse)), - PublicDashboards: mockedStoreResponse, - }, - Err: nil, - }, - want: &PublicDashboardListResponseWithPagination{ - Page: 1, - PerPage: 50, - TotalCount: int64(len(expectedFinalResponse)), - PublicDashboards: expectedFinalResponse, - }, - wantErr: assert.NoError, - }, - { - name: "should an empty response for a user with no access", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, UserID: 4, Permissions: map[int64]map[string][]string{}}, - OrgID: 1, - Page: 1, - Limit: 50, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: &PublicDashboardListResponseWithPagination{ - TotalCount: int64(len(mockedStoreResponse)), - PublicDashboards: mockedStoreResponse, - }, - Err: nil, - }, - want: &PublicDashboardListResponseWithPagination{ - Page: 1, - PerPage: 50, - TotalCount: 0, - PublicDashboards: []*PublicDashboardListResponse{}, - }, - wantErr: assert.NoError, - }, - { - name: "should return correct pagination response if limited", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, UserID: 1, Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"*"}, "folders:read": {"*"}}}}, - OrgID: 1, - Page: 1, - Limit: 2, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: &PublicDashboardListResponseWithPagination{ - TotalCount: int64(len(mockedStoreResponse)), - PublicDashboards: mockedStoreResponse, - }, - Err: nil, - }, - want: &PublicDashboardListResponseWithPagination{ - Page: 1, - PerPage: 2, - TotalCount: 4, - PublicDashboards: expectedFinalResponse[:2], - }, - wantErr: assert.NoError, - }, - { - name: "should return correct page", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, UserID: 1, Permissions: map[int64]map[string][]string{1: {"dashboards:read": {"*"}, "folders:read": {"*"}}}}, - OrgID: 1, - Page: 2, - Limit: 2, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: &PublicDashboardListResponseWithPagination{ - TotalCount: int64(len(mockedStoreResponse)), - PublicDashboards: mockedStoreResponse, - }, - Err: nil, - }, - want: &PublicDashboardListResponseWithPagination{ - Page: 2, - PerPage: 2, - TotalCount: 4, - PublicDashboards: expectedFinalResponse[2:], - }, - wantErr: assert.NoError, - }, - { - name: "should return error when store returns error", - args: args{ - ctx: context.Background(), - query: &PublicDashboardListQuery{ - User: &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ - 1: {"dashboards:read": {"dashboards:uid:0S6TmO67z"}}}, - }, - OrgID: 1, - Page: 1, - Limit: 50, - }, - }, - mockResponse: &mockResponse{ - PublicDashboardListResponseWithPagination: nil, - Err: errors.New("an err"), - }, - want: nil, - wantErr: assert.Error, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - store := NewFakePublicDashboardStore(t) - store.On("FindAll", mock.Anything, mock.Anything). - Return(tt.mockResponse.PublicDashboardListResponseWithPagination, tt.mockResponse.Err) - pd, _, _ := newPublicDashboardServiceImpl(t, testDB, cfg, store, dashboardService, nil) - pd.ac = ac - - got, err := pd.FindAllWithPagination(tt.args.ctx, tt.args.query) - if !tt.wantErr(t, err, fmt.Sprintf("FindAllWithPagination(%v, %v)", tt.args.ctx, tt.args.query)) { - return - } - assert.Equalf(t, tt.want, got, "FindAllWithPagination(%v, %v)", tt.args.ctx, tt.args.query) - }) - } -} - func TestPublicDashboardServiceImpl_NewPublicDashboardUid(t *testing.T) { mockedDashboard := &PublicDashboard{ IsEnabled: true, diff --git a/pkg/tests/api/publicdashboards/public_dashboard_query_test.go b/pkg/tests/api/publicdashboards/public_dashboard_query_test.go new file mode 100644 index 00000000000..7f12940c705 --- /dev/null +++ b/pkg/tests/api/publicdashboards/public_dashboard_query_test.go @@ -0,0 +1,195 @@ +package publicdashboards + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +func TestPublicDashboardQueryAPI(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + EnableFeatureToggles: []string{ + featuremgmt.FlagPublicDashboardsEmailSharing, + }, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + adminUsername := fmt.Sprintf("testadmin-%d", time.Now().UnixNano()) + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Login: adminUsername, + Password: "admin", + IsAdmin: true, + }) + adminClient := createHTTPClient(grafanaListedAddr, adminUsername, "admin") + + datasourcePayload := map[string]interface{}{ + "name": "Test Data Source", + "type": "prometheus", + "uid": "prometheus", + "url": "http://localhost:9090", + "access": "proxy", + } + datasourceBytes, err := json.Marshal(datasourcePayload) + require.NoError(t, err) + var datasourceResult map[string]interface{} + createDatasourceResp := doRequest(t, adminClient, "POST", "/api/datasources", datasourceBytes, &datasourceResult) + require.Equal(t, 200, createDatasourceResp.StatusCode) + + t.Run("unauthenticated user can query public dashboard panel", func(t *testing.T) { + // create dashboard first + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Test Dashboard for Query", + "time": map[string]interface{}{ + "from": "now-1h", + "to": "now", + }, + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + "targets": []map[string]interface{}{ + { + "refId": "A", + "expr": "up", + "datasource": map[string]interface{}{ + "type": "prometheus", + "uid": "prometheus", + }, + }, + }, + }, + }, + }, + "folderUid": "", + "overwrite": false, + } + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + var dashboardResult map[string]interface{} + createDashboardResp := doRequest(t, adminClient, "POST", "/api/dashboards/db", payloadBytes, &dashboardResult) + require.Equal(t, 200, createDashboardResp.StatusCode) + + // make it public + dashboardUID := dashboardResult["uid"].(string) + publicDashboardPayload := map[string]interface{}{ + "isEnabled": true, + "annotationsEnabled": false, + "timeSelectionEnabled": false, + "share": "public", + } + payloadBytes, err = json.Marshal(publicDashboardPayload) + require.NoError(t, err) + createURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUID) + var publicDashboard map[string]interface{} + createResp := doRequest(t, adminClient, "POST", createURL, payloadBytes, &publicDashboard) + require.Equal(t, 200, createResp.StatusCode) + assert.Equal(t, true, publicDashboard["isEnabled"]) + assert.NotEmpty(t, publicDashboard["accessToken"]) + + // test unauthenticated query to the public dashboard panel + accessToken := publicDashboard["accessToken"].(string) + queryPayload := map[string]interface{}{} + queryBytes, err := json.Marshal(queryPayload) + require.NoError(t, err) + queryURL := fmt.Sprintf("/api/public/dashboards/%s/panels/1/query", accessToken) + unauthenticatedClient := createUnauthenticatedClient(grafanaListedAddr) + + var queryResult map[string]interface{} + doRequest(t, unauthenticatedClient, "POST", queryURL, queryBytes, &queryResult) + assert.NotNil(t, queryResult["results"]) + results := queryResult["results"].(map[string]interface{}) + assert.NotNil(t, results["A"]) + }) + + t.Run("unauthenticated user cannot query disabled public dashboard", func(t *testing.T) { + // create the dashboard + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Test Disabled Dashboard", + "time": map[string]interface{}{ + "from": "now-1h", + "to": "now", + }, + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + } + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + var dashboardResult map[string]interface{} + createDashboardResp := doRequest(t, adminClient, "POST", "/api/dashboards/db", payloadBytes, &dashboardResult) + require.Equal(t, 200, createDashboardResp.StatusCode) + + // make it a disabled public dashboard + dashboardUID := dashboardResult["uid"].(string) + publicDashboardPayload := map[string]interface{}{ + "isEnabled": false, + "annotationsEnabled": false, + "timeSelectionEnabled": true, + "share": "public", + } + payloadBytes, err = json.Marshal(publicDashboardPayload) + require.NoError(t, err) + createURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUID) + var publicDashboard map[string]interface{} + createResp := doRequest(t, adminClient, "POST", createURL, payloadBytes, &publicDashboard) + require.Equal(t, 200, createResp.StatusCode) + assert.Equal(t, false, publicDashboard["isEnabled"]) + assert.NotEmpty(t, publicDashboard["accessToken"]) + + accessToken := publicDashboard["accessToken"].(string) + + queryPayload := map[string]interface{}{ + "intervalMs": 1000, + "maxDataPoints": 100, + "timeRange": map[string]interface{}{ + "from": "now-1h", + "to": "now", + }, + } + queryBytes, err := json.Marshal(queryPayload) + require.NoError(t, err) + + // should not be able to query anymore + queryURL := fmt.Sprintf("/api/public/dashboards/%s/panels/1/query", accessToken) + unauthenticatedClient := createUnauthenticatedClient(grafanaListedAddr) + var queryResult map[string]interface{} + queryResp := doRequest(t, unauthenticatedClient, "POST", queryURL, queryBytes, &queryResult) + require.Equal(t, 403, queryResp.StatusCode) + require.Nil(t, queryResult["results"]) + }) +} + +func createUnauthenticatedClient(host string) *httpClient { + baseURL := fmt.Sprintf("http://%s", host) + return &httpClient{ + baseURL: baseURL, + client: &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} diff --git a/pkg/tests/api/publicdashboards/public_dashboards_api_test.go b/pkg/tests/api/publicdashboards/public_dashboards_api_test.go new file mode 100644 index 00000000000..d4a97e068a7 --- /dev/null +++ b/pkg/tests/api/publicdashboards/public_dashboards_api_test.go @@ -0,0 +1,439 @@ +package publicdashboards + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestPublicDashboardsAPI(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + EnableFeatureToggles: []string{ + featuremgmt.FlagPublicDashboardsEmailSharing, + }, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + adminUsername := fmt.Sprintf("testadmin-%d", time.Now().UnixNano()) + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Login: adminUsername, + Password: "admin", + IsAdmin: true, + }) + adminClient := createHTTPClient(grafanaListedAddr, adminUsername, "admin") + + t.Run("should create, get, update, and delete public dashboard", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Test Dashboard", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + } + + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + + var dashboardResult map[string]interface{} + createDashboardResp := doRequest(t, adminClient, "POST", "/api/dashboards/db", payloadBytes, &dashboardResult) + require.Equal(t, 200, createDashboardResp.StatusCode) + + dashboardUID := dashboardResult["uid"].(string) + + var listResult map[string]interface{} + doRequest(t, adminClient, "GET", "/api/dashboards/public-dashboards", nil, &listResult) + publicDashboardPayload := map[string]interface{}{ + "isEnabled": true, + "annotationsEnabled": false, + "timeSelectionEnabled": true, + "share": "public", + } + + payloadBytes, err = json.Marshal(publicDashboardPayload) + require.NoError(t, err) + + createURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUID) + var publicDashboard map[string]interface{} + createResp := doRequest(t, adminClient, "POST", createURL, payloadBytes, &publicDashboard) + require.Equal(t, 200, createResp.StatusCode) + assert.Equal(t, true, publicDashboard["isEnabled"]) + assert.Equal(t, false, publicDashboard["annotationsEnabled"]) + assert.Equal(t, true, publicDashboard["timeSelectionEnabled"]) + assert.Equal(t, "public", publicDashboard["share"]) + assert.NotEmpty(t, publicDashboard["accessToken"]) + assert.NotEmpty(t, publicDashboard["uid"]) + + accessToken := publicDashboard["accessToken"].(string) + publicDashboardUID := publicDashboard["uid"].(string) + + // get the public dashboard + getURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUID) + var retrievedPD map[string]interface{} + getResp := doRequest(t, adminClient, "GET", getURL, nil, &retrievedPD) + require.Equal(t, 200, getResp.StatusCode) + + // view the public dashboard + viewURL := fmt.Sprintf("/api/public/dashboards/%s", accessToken) + var dashboardData map[string]interface{} + viewResp := doRequest(t, adminClient, "GET", viewURL, nil, &dashboardData) + require.Equal(t, 200, viewResp.StatusCode) + assert.Equal(t, "Test Dashboard", dashboardData["dashboard"].(map[string]interface{})["title"]) + assert.Equal(t, "Test Panel", dashboardData["dashboard"].(map[string]interface{})["panels"].([]interface{})[0].(map[string]interface{})["title"]) + + updatePayload := map[string]interface{}{ + "isEnabled": false, + "annotationsEnabled": true, + "timeSelectionEnabled": false, + "share": "email", + } + updateBytes, err := json.Marshal(updatePayload) + require.NoError(t, err) + updateURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", dashboardUID, publicDashboardUID) + var updatedPD map[string]interface{} + updateResp := doRequest(t, adminClient, "PATCH", updateURL, updateBytes, &updatedPD) + require.Equal(t, 200, updateResp.StatusCode) + assert.Equal(t, false, updatedPD["isEnabled"]) + assert.Equal(t, true, updatedPD["annotationsEnabled"]) + assert.Equal(t, false, updatedPD["timeSelectionEnabled"]) + assert.Equal(t, "email", updatedPD["share"]) + + deleteURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", dashboardUID, publicDashboardUID) + var deleteResult map[string]interface{} + deleteResp := doRequest(t, adminClient, "DELETE", deleteURL, nil, &deleteResult) + require.Equal(t, 200, deleteResp.StatusCode) + var getAfterDeleteResult map[string]interface{} + getAfterDeleteResp := doRequest(t, adminClient, "GET", getURL, nil, &getAfterDeleteResult) + require.Equal(t, 404, getAfterDeleteResp.StatusCode) + }) + + t.Run("should list public dashboards", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Test Dashboard for List", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + } + + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + + var dashboardResult map[string]interface{} + createDashboardResp := doRequest(t, adminClient, "POST", "/api/dashboards/db", payloadBytes, &dashboardResult) + require.Equal(t, 200, createDashboardResp.StatusCode) + dashboardUID := dashboardResult["uid"].(string) + + publicDashboardPayload := map[string]interface{}{ + "isEnabled": true, + "annotationsEnabled": false, + "timeSelectionEnabled": true, + "share": "public", + } + + payloadBytes, err = json.Marshal(publicDashboardPayload) + require.NoError(t, err) + + createURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUID) + var createResult map[string]interface{} + createResp := doRequest(t, adminClient, "POST", createURL, payloadBytes, &createResult) + require.Equal(t, 200, createResp.StatusCode) + + var listData map[string]interface{} + listResp := doRequest(t, adminClient, "GET", "/api/dashboards/public-dashboards", nil, &listData) + require.Equal(t, 200, listResp.StatusCode) + assert.NotEmpty(t, listData["publicDashboards"]) + publicDashboards := listData["publicDashboards"].([]interface{}) + assert.GreaterOrEqual(t, len(publicDashboards), 1) + }) + + t.Run("should handle invalid access token", func(t *testing.T) { + var viewResult map[string]interface{} + viewResp := doRequest(t, adminClient, "GET", "/api/public/dashboards/invalid-token", nil, &viewResult) + require.Equal(t, 400, viewResp.StatusCode) + }) + + t.Run("should handle disabled public dashboard", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Test Dashboard Disabled", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + } + + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + + var dashboardResult map[string]interface{} + createDashboardResp := doRequest(t, adminClient, "POST", "/api/dashboards/db", payloadBytes, &dashboardResult) + require.Equal(t, 200, createDashboardResp.StatusCode) + + dashboardUID := dashboardResult["uid"].(string) + publicDashboardPayload := map[string]interface{}{ + "isEnabled": false, + "annotationsEnabled": false, + "timeSelectionEnabled": true, + "share": "public", + } + + payloadBytes, err = json.Marshal(publicDashboardPayload) + require.NoError(t, err) + + createURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUID) + var publicDashboard map[string]interface{} + createResp := doRequest(t, adminClient, "POST", createURL, payloadBytes, &publicDashboard) + require.Equal(t, 200, createResp.StatusCode) + accessToken := publicDashboard["accessToken"].(string) + + var viewResult map[string]interface{} + viewResp := doRequest(t, adminClient, "GET", fmt.Sprintf("/api/public/dashboards/%s", accessToken), nil, &viewResult) + require.Equal(t, 403, viewResp.StatusCode) + }) + + t.Run("permission test", func(t *testing.T) { + dashboards := []map[string]interface{}{ + { + "dashboard": map[string]interface{}{ + "title": "test", + "uid": "9S6TmO67z", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + }, + { + "dashboard": map[string]interface{}{ + "title": "my first dashboard", + "uid": "1S6TmO67z", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + }, + { + "dashboard": map[string]interface{}{ + "title": "my second dashboard", + "uid": "2S6TmO67z", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + }, + { + "dashboard": map[string]interface{}{ + "title": "my zero dashboard", + "uid": "0S6TmO67z", + "panels": []map[string]interface{}{ + { + "id": 1, + "type": "stat", + "title": "Test Panel", + }, + }, + }, + "folderUid": "", + "overwrite": false, + }, + } + + dashboardUIDs := make([]string, len(dashboards)) + publicDashboardUIDs := make([]string, len(dashboards)) + + for i, dashboardPayload := range dashboards { + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + + var dashboardResult map[string]interface{} + createDashboardResp := doRequest(t, adminClient, "POST", "/api/dashboards/db", payloadBytes, &dashboardResult) + require.Equal(t, 200, createDashboardResp.StatusCode) + dashboardUIDs[i] = dashboardResult["uid"].(string) + + isEnabled := i != 1 + publicDashboardPayload := map[string]interface{}{ + "isEnabled": isEnabled, + "annotationsEnabled": false, + "timeSelectionEnabled": true, + "share": "public", + } + + payloadBytes, err = json.Marshal(publicDashboardPayload) + require.NoError(t, err) + + createURL := fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards", dashboardUIDs[i]) + var publicDashboard map[string]interface{} + createResp := doRequest(t, adminClient, "POST", createURL, payloadBytes, &publicDashboard) + require.Equal(t, 200, createResp.StatusCode) + publicDashboardUIDs[i] = publicDashboard["uid"].(string) + } + + t.Run("admin user should see all dashboards", func(t *testing.T) { + var listData map[string]interface{} + listResp := doRequest(t, adminClient, "GET", "/api/dashboards/public-dashboards?page=1&perpage=50", nil, &listData) + require.Equal(t, 200, listResp.StatusCode) + + totalCount := int64(listData["totalCount"].(float64)) + assert.GreaterOrEqual(t, totalCount, int64(4)) + }) + + t.Run("user with access to just one dashboard should see only that dashboard", func(t *testing.T) { + limitedUserUsername := fmt.Sprintf("limiteduser-%d", time.Now().UnixNano()) + limitedUserID := tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleNone), + Login: limitedUserUsername, + Password: "password", + IsAdmin: false, + }) + limitedUserClient := createHTTPClient(grafanaListedAddr, limitedUserUsername, "password") + permissionPayload := map[string]interface{}{ + "permission": "View", + } + permissionBytes, err := json.Marshal(permissionPayload) + require.NoError(t, err) + + permissionURL := fmt.Sprintf("/api/access-control/dashboards/9S6TmO67z/users/%d", limitedUserID) + var permissionResult map[string]interface{} + permissionResp := doRequest(t, adminClient, "POST", permissionURL, permissionBytes, &permissionResult) + require.Equal(t, 200, permissionResp.StatusCode) + + var listData map[string]interface{} + listResp := doRequest(t, limitedUserClient, "GET", "/api/dashboards/public-dashboards?page=1&perpage=50", nil, &listData) + require.Equal(t, 200, listResp.StatusCode) + + totalCount := int64(listData["totalCount"].(float64)) + assert.Equal(t, int64(1), totalCount) + }) + + t.Run("pagination should work correctly", func(t *testing.T) { + var listData map[string]interface{} + listResp := doRequest(t, adminClient, "GET", "/api/dashboards/public-dashboards?page=1&perpage=2", nil, &listData) + require.Equal(t, 200, listResp.StatusCode) + assert.NotEmpty(t, listData["publicDashboards"]) + publicDashboards := listData["publicDashboards"].([]interface{}) + assert.Equal(t, 2, len(publicDashboards)) + totalCount := int64(listData["totalCount"].(float64)) + assert.GreaterOrEqual(t, totalCount, int64(4)) + + var listDataPage2 map[string]interface{} + listRespPage2 := doRequest(t, adminClient, "GET", "/api/dashboards/public-dashboards?page=2&perpage=2", nil, &listDataPage2) + require.Equal(t, 200, listRespPage2.StatusCode) + publicDashboardsPage2 := listDataPage2["publicDashboards"].([]interface{}) + assert.Equal(t, 2, len(publicDashboardsPage2)) + }) + }) +} + +type httpClient struct { + baseURL string + client *http.Client +} + +func createHTTPClient(host, username, password string) *httpClient { + baseURL := fmt.Sprintf("http://%s:%s@%s", username, password, host) + return &httpClient{ + baseURL: baseURL, + client: &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +type httpResponse struct { + StatusCode int + Body []byte +} + +func doRequest(t *testing.T, client *httpClient, method, path string, body []byte, result interface{}) httpResponse { + t.Helper() + + var req *http.Request + var err error + + url := client.baseURL + path + if body != nil { + req, err = http.NewRequest(method, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req, err = http.NewRequest(method, url, nil) + } + require.NoError(t, err) + + resp, err := client.client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() // nolint:errcheck + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + response := httpResponse{ + StatusCode: resp.StatusCode, + Body: respBody, + } + + if result != nil && len(respBody) > 0 { + err = json.Unmarshal(respBody, result) + require.NoError(t, err) + } + + return response +} From 8b940f210f3913078a01366c05a57f835b4ae6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 28 Jul 2025 19:58:11 +0200 Subject: [PATCH 20/39] datasources: querier: temporary concurrency fix (#108503) --- pkg/services/query/query.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index e2c3f68043d..73612d41f22 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -223,6 +223,7 @@ func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheSer dataSourceRequestValidator: validations.ProvideValidator(), mtDatasourceClientBuilder: mtDatasourceClientBuilder, headers: headers, + concurrentQueryLimit: 16, // TODO: make it configurable } return s.QueryData(ctx, nil, false, reqDTO) } From 4b9e03e7c07d70c62c5cadba3a73c94e1268a8df Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 28 Jul 2025 17:03:55 -0400 Subject: [PATCH 21/39] TableNG: Simplify row height calculation and make more extensible (#108624) * TableNG: Simplify row height calculation and make more extensible * add a cache for the results of rowHeight when it's a function * JSDoc comment for util * from the other branch, copy the related code and tests * rework the line counters a bit, limit line counting to string fields * add test for string case for buildRowLineCounters * add the concept of estimates vs. counts * add a comment * ceil, not floor * try to be as terse as possible * test for estimates * comment the type * more comment in test * swap * fix #108804 * convert em letter spacing to px for avgCharWidth calculation * tweak whee em-to-px math happens, and force count to occur on every row when wrap is on to avoid short row issues * update test * update to clamp single-line estimation using a hardcoded value (0.85) * add assertion for not calling counter in that case * uwrap 0.1.2 * fix betterer issues * fix typography ctx extra import --------- Co-authored-by: Leon Sorokin --- packages/grafana-ui/package.json | 2 +- .../Table/TableNG/Cells/ImageCell.tsx | 7 +- .../src/components/Table/TableNG/TableNG.tsx | 53 ++- .../src/components/Table/TableNG/constants.ts | 4 +- .../components/Table/TableNG/hooks.test.ts | 285 ++++++++++-- .../src/components/Table/TableNG/hooks.ts | 178 ++------ .../src/components/Table/TableNG/types.ts | 26 ++ .../components/Table/TableNG/utils.test.ts | 409 ++++++++++++++---- .../src/components/Table/TableNG/utils.ts | 218 ++++++++-- yarn.lock | 10 +- 10 files changed, 853 insertions(+), 339 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 553dd3b8fa7..8738aab4d7c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -131,7 +131,7 @@ "tslib": "2.8.1", "uplot": "1.6.32", "uuid": "11.1.0", - "uwrap": "0.1.1" + "uwrap": "0.1.2" }, "devDependencies": { "@babel/core": "7.28.0", diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index 9953ef0948e..a9fd4420ff3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -8,11 +8,8 @@ import { TableCellDisplayMode } from '../../types'; import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; import { ImageCellProps } from '../types'; -const DATALINKS_HEIGHT_OFFSET = 10; - export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => { - const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET; - const styles = useStyles2(getStyles, calculatedHeight, justifyContent); + const styles = useStyles2(getStyles, height, justifyContent); const { text } = field.display!(value); const { alt, title } = @@ -27,7 +24,7 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r ); }; -const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ +const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ image: css({ height, width: 'auto', diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index c84fb2d4194..40cd49278d3 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -26,7 +26,7 @@ import { ReducerID, } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { FieldColorModeId, TableCellHeight } from '@grafana/schema'; +import { FieldColorModeId } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; import { ContextMenu } from '../../ContextMenu/ContextMenu'; @@ -52,29 +52,30 @@ import { useRowHeight, useScrollbarWidth, useSortedRows, - useTypographyCtx, } from './hooks'; import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types'; import { + applySort, + computeColWidths, + createTypographyContext, + displayJsonValue, + extractPixelValue, frameToRecords, + getAlignment, + getApplyToRowBgFn, + getCellColors, + getCellLinks, + getCellOptions, getDefaultRowHeight, getDisplayName, getIsNestedTable, - getVisibleFields, - shouldTextOverflow, - getApplyToRowBgFn, - computeColWidths, - applySort, - getCellColors, - getCellOptions, - shouldTextWrap, - isCellInspectEnabled, - getCellLinks, - withDataLinksActionsTooltip, - displayJsonValue, - getAlignment, getJustifyContent, + getVisibleFields, + isCellInspectEnabled, + shouldTextOverflow, + shouldTextWrap, TextAlign, + withDataLinksActionsTooltip, } from './utils'; type CellRootRenderer = (key: React.Key, props: CellRendererProps) => React.ReactNode; @@ -160,7 +161,6 @@ export function TableNG(props: TableNGProps) { } = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy }); const defaultRowHeight = getDefaultRowHeight(theme, cellHeight); - const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm); const [isInspecting, setIsInspecting] = useState(false); const [expandedRows, setExpandedRows] = useState(() => new Set()); @@ -172,13 +172,20 @@ export function TableNG(props: TableNGProps) { () => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth, [width, hasNestedFrames, scrollbarWidth] ); - const typographyCtx = useTypographyCtx(); + const typographyCtx = useMemo( + () => + createTypographyContext( + theme.typography.fontSize, + theme.typography.fontFamily, + extractPixelValue(theme.typography.body.letterSpacing!) * theme.typography.fontSize + ), + [theme] + ); const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]); const headerHeight = useHeaderHeight({ columnWidths: widths, fields: visibleFields, enabled: hasHeader, - defaultHeight: defaultHeaderHeight, sortColumns, showTypeIcons: showTypeIcons ?? false, typographyCtx, @@ -285,7 +292,6 @@ export function TableNG(props: TableNGProps) { }; let lastRowIdx = -1; - let _rowHeight = 0; // shared when whole row will be styled by a single cell's color let rowCellStyle: Partial = { color: undefined, @@ -381,7 +387,6 @@ export function TableNG(props: TableNGProps) { // meh, this should be cached by the renderRow() call? if (rowIdx !== lastRowIdx) { - _rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; lastRowIdx = rowIdx; rowCellStyle.color = undefined; @@ -420,6 +425,9 @@ export function TableNG(props: TableNGProps) { const renderCellContent = (props: RenderCellProps): JSX.Element => { const rowIdx = props.row.__index; const value = props.row[props.column.key]; + // TODO: it would be nice to get rid of passing height down as a prop. but this value + // is cached so the cost of calling for every cell is low. + const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight; const frame = data; return ( @@ -428,7 +436,7 @@ export function TableNG(props: TableNGProps) { cellOptions, frame, field, - height: _rowHeight, + height, justifyContent, rowIdx, theme, @@ -580,7 +588,7 @@ export function TableNG(props: TableNGProps) { {...commonDataGridProps} className={clsx(styles.grid, styles.gridNested)} headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })} - headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0} + headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0} columns={nestedColumns} rows={expandedRecords} renderers={{ renderRow, renderCell: renderCellRoot }} @@ -599,7 +607,6 @@ export function TableNG(props: TableNGProps) { crossFilterOrder, crossFilterRows, data, - defaultHeaderHeight, defaultRowHeight, enableSharedCrosshair, expandedRows, diff --git a/packages/grafana-ui/src/components/Table/TableNG/constants.ts b/packages/grafana-ui/src/components/Table/TableNG/constants.ts index 31a537136d2..7638dd98804 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/constants.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/constants.ts @@ -13,7 +13,9 @@ export const TABLE = { PAGINATION_LIMIT: 750, SCROLL_BAR_WIDTH: 8, SCROLL_BAR_MARGIN: 2, + FONT_SIZE: 14, LINE_HEIGHT: 22, + HEADER_HEIGHT: 28, NESTED_NO_DATA_HEIGHT: 60, - BORDER_RIGHT: 0.666667, + BORDER_RIGHT: 1, }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts index 4df9128205a..f7a1a1b858a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.test.ts @@ -1,23 +1,19 @@ import { act, renderHook } from '@testing-library/react'; -import { varPreLine } from 'uwrap'; import { cacheFieldDisplayNames, createDataFrame, Field, FieldType } from '@grafana/data'; +import { TableCellDisplayMode } from '@grafana/schema'; +import { TABLE } from './constants'; import { useFilteredRows, usePaginatedRows, useSortedRows, useFooterCalcs, useHeaderHeight, - useTypographyCtx, + useRowHeight, } from './hooks'; - -jest.mock('uwrap', () => ({ - // ...jest.requireActual('uwrap'), - varPreLine: jest.fn(() => ({ - count: jest.fn(() => 1), - })), -})); +import { TableRow } from './types'; +import { createTypographyContext } from './utils'; describe('TableNG hooks', () => { function setupData() { @@ -28,21 +24,21 @@ describe('TableNG hooks', () => { type: FieldType.string, display: (v) => ({ text: v as string, numeric: NaN }), config: {}, - values: [], + values: ['Alice', 'Bob', 'Charlie'], }, { name: 'age', type: FieldType.number, display: (v) => ({ text: (v as number).toString(), numeric: v as number }), config: {}, - values: [], + values: [30, 25, 35], }, { name: 'active', type: FieldType.boolean, display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }), config: {}, - values: [], + values: [true, false, true], }, ]; @@ -149,7 +145,7 @@ describe('TableNG hooks', () => { height: 300, width: 800, enabled: false, - headerHeight: 28, + headerHeight: TABLE.HEADER_HEIGHT, footerHeight: 0, }) ); @@ -201,7 +197,7 @@ describe('TableNG hooks', () => { height: 140, width: 800, rowHeight: 10, - headerHeight: 28, + headerHeight: TABLE.HEADER_HEIGHT, footerHeight: 45, }) ); @@ -429,16 +425,16 @@ describe('TableNG hooks', () => { }); describe('useHeaderHeight', () => { + const typographyCtx = createTypographyContext(14, 'sans-serif'); + it('should return 0 when no header is present', () => { const { fields } = setupData(); const { result } = renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields, columnWidths: [], enabled: false, typographyCtx, - defaultHeight: 28, sortColumns: [], }); }); @@ -448,31 +444,20 @@ describe('TableNG hooks', () => { it('should return the default height when wrap is disabled', () => { const { fields } = setupData(); const { result } = renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields, columnWidths: [], enabled: true, typographyCtx, - defaultHeight: 28, sortColumns: [], }); }); - expect(result.current).toBe(22); + expect(result.current).toBe(28); }); it('should return the appropriate height for wrapped text', () => { - // Simulate 2 lines of text - jest.mocked(varPreLine).mockReturnValue({ - count: jest.fn(() => 2), - each: jest.fn(), - split: jest.fn(), - test: jest.fn(), - }); - const { fields } = setupData(); const { result } = renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields: fields.map((field) => { if (field.name === 'name') { @@ -492,8 +477,7 @@ describe('TableNG hooks', () => { }), columnWidths: [100, 100, 100], enabled: true, - typographyCtx: { ...typographyCtx, avgCharWidth: 5 }, - defaultHeight: 28, + typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) }, sortColumns: [], }); }); @@ -504,19 +488,9 @@ describe('TableNG hooks', () => { it('should calculate the available width for a header cell based on the icons rendered within it', () => { const countFn = jest.fn(() => 1); - // Simulate 2 lines of text - jest.mocked(varPreLine).mockReturnValue({ - count: countFn, - each: jest.fn(), - split: jest.fn(), - test: jest.fn(), - }); - const { fields } = setupData(); renderHook(() => { - const typographyCtx = useTypographyCtx(); - return useHeaderHeight({ fields: fields.map((field) => { if (field.name === 'name') { @@ -536,17 +510,15 @@ describe('TableNG hooks', () => { }), columnWidths: [100, 100, 100], enabled: true, - typographyCtx: { ...typographyCtx, avgCharWidth: 10 }, - defaultHeight: 28, + typographyCtx: { ...typographyCtx, wrappedCount: countFn }, sortColumns: [], showTypeIcons: false, }); }); - expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87); + expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86); renderHook(() => { - const typographyCtx = useTypographyCtx(); return useHeaderHeight({ fields: fields.map((field) => { if (field.name === 'name') { @@ -567,14 +539,233 @@ describe('TableNG hooks', () => { }), columnWidths: [100, 100, 100], enabled: true, - typographyCtx: { ...typographyCtx, avgCharWidth: 10 }, - defaultHeight: 28, + typographyCtx: { ...typographyCtx, wrappedCount: countFn }, sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }], showTypeIcons: true, }); }); - expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27); + expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26); + }); + }); + + describe('useRowHeight', () => { + const typographyCtx = createTypographyContext(14, 'sans-serif'); + + it('returns the default height if there are no wrapped columns or nested frames', () => { + const { fields } = setupData(); + + const defaultHeight = 40; + + expect( + renderHook(() => { + return useRowHeight({ + fields, + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: false, + expandedRows: new Set(), + }); + }).result.current + ).toBe(defaultHeight); + }); + + describe('nested frames', () => { + it('returns 0 if the parent row is not expanded', () => { + const { fields } = setupData(); + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight: 40, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set(), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ __depth: 1, data: createDataFrame({ fields }), __index: 0 }); + }).result.current + ).toBe(0); + }); + + it('returns a static height if there are no rows in the nested frame', () => { + const { fields } = setupData(); + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight: 40, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set([0]), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ + __depth: 1, + data: undefined, + __index: 0, + }); + }).result.current + ).toBe(TABLE.NESTED_NO_DATA_HEIGHT + TABLE.CELL_PADDING * 2); + }); + + it('calculates the height to return based on the number of rows in the nested frame', () => { + const { fields } = setupData(); + + const defaultHeight = 40; + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set([0]), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ + __index: 0, + __depth: 1, + data: createDataFrame({ fields }), + }); + }).result.current + ).toBe(defaultHeight * 4 + TABLE.CELL_PADDING * 2); // 3 rows + header + padding + }); + + it('removes the header if configured', () => { + const { fields } = setupData(); + + const defaultHeight = 40; + + expect( + renderHook(() => { + const rowHeight = useRowHeight({ + fields: [ + { name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} }, + ], + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: true, + expandedRows: new Set([0]), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight({ + __index: 0, + __depth: 1, + data: createDataFrame({ fields, meta: { custom: { noHeader: true } } }), + }); + }).result.current + ).toBe(defaultHeight * 3 + TABLE.CELL_PADDING * 2); // 3 rows + padding (no header) + }); + }); + + // we test the lineCounters and getRowHeight directly to check that all of that + // math is working correctly. we mainly want to confirm here that the + // cache is clearing and that the local logic in this hook works. + describe('wrapped columns', () => { + let rows: TableRow[]; + let fieldsWithWrappedText: Field[]; + + beforeEach(() => { + const { fields, rows: _rows } = setupData(); + + rows = _rows; + fieldsWithWrappedText = fields.map((field) => { + if (field.name === 'name') { + return { + ...field, + name: 'Longer name that needs wrapping', + config: { + ...field.config, + custom: { + ...field.config?.custom, + cellOptions: { + cellType: TableCellDisplayMode.Auto, + wrapText: true, + }, + }, + }, + }; + } + return field; + }); + }); + + it('handles changes to default height on re-render', () => { + const { result, rerender } = renderHook( + ({ defaultHeight }) => { + const rowHeight = useRowHeight({ + fields: fieldsWithWrappedText, + columnWidths: [100, 100, 100], + defaultHeight, + typographyCtx: typographyCtx, + hasNestedFrames: false, + expandedRows: new Set(), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight; + }, + { + initialProps: { defaultHeight: 40 }, + } + ); + + expect(result.current(rows[0])).toBe(40); + + // change the column widths + rerender({ defaultHeight: 50 }); + + expect(result.current(rows[0])).toBe(50); + }); + + it('adjusts the width of the columns based on the cell padding and border', () => { + fieldsWithWrappedText[0].values[0] = 'Annie Lennox'; + + const wrappedCountFn = jest.fn(() => 2); + const estimateLinesFn = jest.fn(() => 2); + const { result } = renderHook(() => { + const rowHeight = useRowHeight({ + fields: fieldsWithWrappedText, + columnWidths: [100, 100, 100], + defaultHeight: 40, + typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn }, + hasNestedFrames: false, + expandedRows: new Set(), + }); + if (typeof rowHeight !== 'function') { + throw new Error('Expected rowHeight to be a function'); + } + return rowHeight; + }); + + expect(result.current(rows[0])).toEqual(expect.any(Number)); + + expect(estimateLinesFn).toHaveBeenCalledWith('Annie Lennox', 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT); + }); }); }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index e5f30a68d27..70a790d9133 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -1,22 +1,20 @@ import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect, RefObject } from 'react'; import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid'; -import { varPreLine } from 'uwrap'; import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data'; -import { useTheme2 } from '../../../themes/ThemeContext'; -import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types'; +import { TableColumnResizeActionCallback } from '../types'; import { TABLE } from './constants'; -import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types'; +import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types'; import { getDisplayName, processNestedTableRows, applySort, - getCellOptions, getColumnTypes, - GetMaxWrapCellOptions, - getMaxWrapCell, + getRowHeight, + buildHeaderLineCounters, + buildRowLineCounters, } from './utils'; // Helper function to get displayed value @@ -314,49 +312,6 @@ export function useFooterCalcs( }, [fields, enabled, footerOptions, isCountRowsSet, rows]); } -interface TypographyCtx { - ctx: CanvasRenderingContext2D; - font: string; - avgCharWidth: number; - calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number; -} - -export function useTypographyCtx(): TypographyCtx { - const theme = useTheme2(); - const typographyCtx = useMemo((): TypographyCtx => { - const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`; - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - // set in grafana/data in createTypography.ts - const letterSpacing = 0.15; - - ctx.letterSpacing = `${letterSpacing}px`; - ctx.font = font; - const txt = - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s"; - const txtWidth = ctx.measureText(txt).width; - const avgCharWidth = txtWidth / txt.length + letterSpacing; - const { count } = varPreLine(ctx); - - const calcRowHeight = (text: string, cellWidth: number, defaultHeight: number) => { - if (text === '') { - return defaultHeight; - } - const numLines = count(text, cellWidth); - const totalHeight = numLines * TABLE.LINE_HEIGHT + 2 * TABLE.CELL_PADDING; - return Math.max(totalHeight, defaultHeight); - }; - - return { - calcRowHeight, - ctx, - font, - avgCharWidth, - }; - }, [theme.typography.fontSize, theme.typography.fontFamily]); - return typographyCtx; -} - const ICON_WIDTH = 16; const ICON_GAP = 4; @@ -364,7 +319,6 @@ interface UseHeaderHeightOptions { enabled: boolean; fields: Field[]; columnWidths: number[]; - defaultHeight: number; sortColumns: SortColumn[]; typographyCtx: TypographyCtx; showTypeIcons?: boolean; @@ -374,12 +328,14 @@ export function useHeaderHeight({ fields, enabled, columnWidths, - defaultHeight, sortColumns, - typographyCtx: { calcRowHeight, avgCharWidth }, + typographyCtx, showTypeIcons = false, }: UseHeaderHeightOptions): number { const perIconSpace = ICON_WIDTH + ICON_GAP; + + const lineCounters = useMemo(() => buildHeaderLineCounters(fields, typographyCtx), [fields, typographyCtx]); + const columnAvailableWidths = useMemo( () => columnWidths.map((c, idx) => { @@ -396,46 +352,26 @@ export function useHeaderHeight({ if (showTypeIcons) { width -= perIconSpace; } - return Math.floor(width); + // sadly, the math for this is off by exactly 1 pixel. shrug. + return Math.floor(width) - 1; }), [fields, columnWidths, sortColumns, showTypeIcons, perIconSpace] ); - const [wrappedColHeaderIdxs, hasWrappedColHeaders] = useMemo(() => { - let hasWrappedColHeaders = false; - return [ - fields.map((field) => { - const wrapText = field.config?.custom?.wrapHeaderText ?? false; - if (wrapText) { - hasWrappedColHeaders = true; - } - return wrapText; - }), - hasWrappedColHeaders, - ]; - }, [fields]); - - const maxWrapCellOptions = useMemo( - () => ({ - colWidths: columnAvailableWidths, - avgCharWidth, - wrappedColIdxs: wrappedColHeaderIdxs, - }), - [columnAvailableWidths, avgCharWidth, wrappedColHeaderIdxs] - ); - - // TODO: is there a less clunky way to subtract the top padding value? const headerHeight = useMemo(() => { if (!enabled) { return 0; } - if (!hasWrappedColHeaders) { - return defaultHeight - TABLE.CELL_PADDING; - } - - const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, -1, maxWrapCellOptions); - return calcRowHeight(maxLinesText, columnAvailableWidths[maxLinesIdx], defaultHeight) - TABLE.CELL_PADDING; - }, [fields, enabled, hasWrappedColHeaders, maxWrapCellOptions, calcRowHeight, columnAvailableWidths, defaultHeight]); + return getRowHeight( + fields, + -1, + columnAvailableWidths, + TABLE.HEADER_HEIGHT, + lineCounters, + TABLE.LINE_HEIGHT, + TABLE.CELL_PADDING + ); + }, [fields, enabled, columnAvailableWidths, lineCounters]); return headerHeight; } @@ -455,42 +391,15 @@ export function useRowHeight({ hasNestedFrames, defaultHeight, expandedRows, - typographyCtx: { calcRowHeight, avgCharWidth }, + typographyCtx, }: UseRowHeightOptions): number | ((row: TableRow) => number) { - const [wrappedColIdxs, hasWrappedCols] = useMemo(() => { - let hasWrappedCols = false; - return [ - fields.map((field) => { - if (field.type !== FieldType.string) { - return false; - } + const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]); + const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]); - const cellOptions = getCellOptions(field); - const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText; - const type = cellOptions.type; - const result = !!wrapText && type !== TableCellDisplayMode.Image; - if (result === true) { - hasWrappedCols = true; - } - return result; - }), - hasWrappedCols, - ]; - }, [fields]); - - const colWidths = useMemo( - () => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT), - [columnWidths] - ); - - const maxWrapCellOptions = useMemo( - () => ({ - colWidths, - avgCharWidth, - wrappedColIdxs, - }), - [colWidths, avgCharWidth, wrappedColIdxs] - ); + const colWidths = useMemo(() => { + const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT; + return columnWidths.map((c) => c - columnWidthAffordance); + }, [columnWidths]); const rowHeight = useMemo(() => { // row height is only complicated when there are nested frames or wrapped columns. @@ -498,6 +407,9 @@ export function useRowHeight({ return defaultHeight; } + // this cache should get blown away on resize, data refresh, updated fields, etc. + // caching by __index is ok because sorting does not modify the __index. + const cache: Array = Array(fields[0].values.length); return (row: TableRow) => { // nested rows if (row.__depth > 0) { @@ -512,23 +424,25 @@ export function useRowHeight({ } const nestedHeaderHeight = row.data?.meta?.custom?.noHeader ? 0 : defaultHeight; - return Math.max(defaultHeight, defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2); + return defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2; } // regular rows - const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions); - return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight); + let result = cache[row.__index]; + if (!result) { + result = cache[row.__index] = getRowHeight( + fields, + row.__index, + colWidths, + defaultHeight, + lineCounters, + TABLE.LINE_HEIGHT, + TABLE.CELL_PADDING * 2 + ); + } + return result; }; - }, [ - calcRowHeight, - defaultHeight, - expandedRows, - fields, - hasNestedFrames, - hasWrappedCols, - maxWrapCellOptions, - colWidths, - ]); + }, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]); return rowHeight; } diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index bee921c1c71..1d736974189 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -261,3 +261,29 @@ export interface ScrollPosition { x: number; y: number; } + +export interface TypographyCtx { + ctx: CanvasRenderingContext2D; + font: string; + avgCharWidth: number; + estimateLines: LineCounter; + wrappedCount: LineCounter; +} + +export type LineCounter = (value: unknown, width: number) => number; +export interface LineCounterEntry { + /** + * given a values and the available width, returns the line count for that value + */ + counter: LineCounter; + /** + * if getting an accurate line count is expensive, you can provide an estimate method + * which will be used when looping over the row. the counter method will only be invoked + * for the cell which is the maximum line count for the row. + */ + estimate?: LineCounter; + /** + * indicates which field indexes of the visible fields this line counter applies to. + */ + fieldIdxs: number[]; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index f7bfcf2cc9d..e86de2a7009 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -16,7 +16,8 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight } import { TableCellDisplayMode } from '../types'; -import { TABLE } from './constants'; +import { COLUMN, TABLE } from './constants'; +import { LineCounterEntry } from './types'; import { extractPixelValue, frameToRecords, @@ -31,8 +32,14 @@ import { getJustifyContent, migrateTableDisplayModeToCellOptions, getColumnTypes, - getMaxWrapCell, + computeColWidths, + getRowHeight, + buildRowLineCounters, + buildHeaderLineCounters, + getTextLineEstimator, + createTypographyContext, applySort, + SINGLE_LINE_ESTIMATE_THRESHOLD, } from './utils'; describe('TableNG utils', () => { @@ -975,117 +982,345 @@ describe('TableNG utils', () => { }); }); - describe('getMaxWrapCell', () => { - it('should return the maximum wrap cell length from field state', () => { - const field1: Field = { - name: 'field1', - type: FieldType.string, - config: {}, - values: ['beep boop', 'foo bar baz', 'lorem ipsum dolor sit amet'], - }; + describe('createTypographyCtx', () => { + // we can't test the effectiveness of this typography context in unit tests, only that it + // actually executed the JS correctly. If you called `count` with a sensible value and width, + // it wouldn't give you a very reasonable answer in Jest's DOM environment for some reason. + it('creates the context using uwrap', () => { + const ctx = createTypographyContext(14, 'sans-serif', 0.15); + expect(ctx).toEqual( + expect.objectContaining({ + font: '14px sans-serif', + ctx: expect.any(CanvasRenderingContext2D), + wrappedCount: expect.any(Function), + estimateLines: expect.any(Function), + avgCharWidth: expect.any(Number), + }) + ); + expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number)); + expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number)); + }); + }); - const field2: Field = { - name: 'field2', - type: FieldType.string, - config: {}, - values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''], - }; + describe('getTextLineEstimator', () => { + const counter = getTextLineEstimator(10); - const field3: Field = { - name: 'field3', - type: FieldType.string, - config: {}, - values: ['foo', 'bar', 'baz'], - // No alignmentFactors in state - }; - - const fields = [field1, field2, field3]; - - const result = getMaxWrapCell(fields, 0, { - colWidths: [30, 50, 100], - avgCharWidth: 5, - wrappedColIdxs: [true, true, true], - }); - expect(result).toEqual({ - text: 'asdfasdf asdfasdf asdfasdf', - idx: 1, - numLines: 2.6, - }); + it('returns -1 if there are no strings or dashes within the string', () => { + expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5)).toBe(-1); }); - it('should take colWidths into account when calculating max wrap cell', () => { + it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => { + expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200)).toBe(2.5); + }); + }); + + describe('buildHeaderLineCounters', () => { + const ctx = { + font: '14px sans-serif', + ctx: {} as CanvasRenderingContext2D, + count: jest.fn(() => 2), + avgCharWidth: 7, + wrappedCount: jest.fn(() => 2), + estimateLines: jest.fn(() => 2), + }; + + it('returns an array of line counters for each column', () => { const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } }, + ]; + const counters = buildHeaderLineCounters(fields, ctx); + expect(counters![0].counter).toEqual(expect.any(Function)); + expect(counters![0].fieldIdxs).toEqual([0, 1]); + }); + + it('does not return the index of columns which are not wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } }, + ]; + + const counters = buildHeaderLineCounters(fields, ctx); + expect(counters![0].fieldIdxs).toEqual([1]); + }); + + it('returns undefined if no columns are wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: {} } }, + ]; + + const counters = buildHeaderLineCounters(fields, ctx); + expect(counters).toBeUndefined(); + }); + }); + + describe('buildRowLineCounters', () => { + const ctx = { + font: '14px sans-serif', + ctx: {} as CanvasRenderingContext2D, + count: jest.fn(() => 2), + wrappedCount: jest.fn(() => 2), + estimateLines: jest.fn(() => 2), + avgCharWidth: 7, + }; + + it('returns an array of line counters for each column', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } }, { - name: 'field', + name: 'Address', type: FieldType.string, - config: {}, - values: ['short', 'a bit longer text'], + values: [], + config: { custom: { cellOptions: { wrapText: true } } }, }, + ]; + const counters = buildRowLineCounters(fields, ctx); + expect(counters![0].counter).toEqual(expect.any(Function)); + expect(counters![0].fieldIdxs).toEqual([0, 1]); + }); + + it('does not return the index of columns which are not wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, { - name: 'field', + name: 'Address', type: FieldType.string, - config: {}, - values: ['short', 'quite a bit longer text'], - }, - { - name: 'field', - type: FieldType.string, - config: {}, - values: ['short', 'less text'], + values: [], + config: { custom: { cellOptions: { wrapText: true } } }, }, ]; - // Simulate a narrow column width that would cause wrapping - const colWidths = [50, 1000, 30]; // 50px width - const avgCharWidth = 5; // Assume average character width is 5px - - const result = getMaxWrapCell(fields, 1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] }); - - // With a 50px width and 5px per character, we can fit 10 characters per line - // "the longest text in this field" has 31 characters, so it should wrap to 4 lines - expect(result).toEqual({ - idx: 0, - numLines: 1.7, - text: 'a bit longer text', - }); + const counters = buildRowLineCounters(fields, ctx); + expect(counters![0].fieldIdxs).toEqual([1]); }); - it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => { + it('does not enable text counting for non-string fields', () => { const fields: Field[] = [ - { - name: 'Field with a very long name', - type: FieldType.string, - config: {}, - values: ['short', 'a bit longer text'], - }, + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: { cellOptions: { wrapText: true } } } }, + ]; + + const counters = buildRowLineCounters(fields, ctx); + // empty array - we had one column that indicated it wraps, but it was numeric, so we just ignore it + expect(counters).toEqual([]); + }); + + it('returns an undefined if no columns are wrapped', () => { + const fields: Field[] = [ + { name: 'Name', type: FieldType.string, values: [], config: { custom: {} } }, + { name: 'Age', type: FieldType.number, values: [], config: { custom: {} } }, + ]; + + const counters = buildRowLineCounters(fields, ctx); + expect(counters).toBeUndefined(); + }); + }); + + describe('getRowHeight', () => { + let fields: Field[]; + let counters: LineCounterEntry[]; + + beforeEach(() => { + fields = [ { name: 'Name', type: FieldType.string, - config: {}, - values: ['short', 'quite a bit longer text'], + values: ['foo', 'bar', 'baz', 'longer one here', 'shorter'], + config: { custom: { cellOptions: { wrapText: true } } }, }, { - name: 'Another field', - type: FieldType.string, - config: {}, - values: ['short', 'less text'], + name: 'Age', + type: FieldType.number, + values: [1, 2, 3, 123456, 789122349932], + config: { custom: { cellOptions: { wrapText: true } } }, }, ]; - - // Simulate a narrow column width that would cause wrapping - const colWidths = [50, 1000, 30]; // 50px width - const avgCharWidth = 5; // Assume average character width is 5px - - const result = getMaxWrapCell(fields, -1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] }); - - // With a 50px width and 5px per character, we can fit 10 characters per line - // "the longest text in this field" has 31 characters, so it should wrap to 4 lines - expect(result).toEqual({ idx: 0, numLines: 2.7, text: 'Field with a very long name' }); + counters = [ + { counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines + { counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), fieldIdxs: [1] }, // Mocked to return a line for every 3 digits of a number + ]; }); - it.todo('should ignore columns which are not wrapped'); + it('should use the default height for single-line rows', () => { + // 1 line @ 20px, 10px vertical padding = 30, minimum is 36 + expect(getRowHeight(fields, 0, [30, 30], 36, counters, 20, 10)).toBe(36); + }); - it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)'); + it('should use the default height for multi-line rows which are shorter than the default height', () => { + // 3 lines @ 5px, 5px vertical padding = 20, minimum is 36 + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 5, 5)).toBe(36); + }); + + it('should return the row height using line counters for multi-line', () => { + // 3 lines @ 20px ('longer', 'one', 'here'), 10px vertical padding + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(70); + + // 4 lines @ 15px (789 122 349 932), 15px vertical padding + expect(getRowHeight(fields, 4, [30, 30], 36, counters, 15, 15)).toBe(75); + }); + + it('should take colWidths into account when calculating max wrap cell', () => { + getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10); + expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50); + expect(counters[1].counter).toHaveBeenCalledWith(123456, 60); + }); + + // this is used to calc wrapped header height + it('should use the display name if the rowIdx is -1', () => { + getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10); + expect(counters[0].counter).toHaveBeenCalledWith('Name', 50); + expect(counters[1].counter).toHaveBeenCalledWith('Age', 60); + }); + + it('should ignore columns which do not have line counters', () => { + const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10); + // 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name) + expect(height).toBe(50); + }); + + it('should return the default height if there are no counters to apply', () => { + const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10); + expect(height).toBe(36); + }); + + describe('estimations vs. precise counts', () => { + beforeEach(() => { + counters = [ + { counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines + { + estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number + counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), + fieldIdxs: [1], + }, + ]; + }); + + // 2 lines @ 20px (123,456), 10px vertical padding. when we did this before, 'longer one here' would win, making it 70px. + // the `estimate` function is picking `123456` as the longer one now (6 lines), then the `counter` function is used + // to calculate the height (2 lines). this is a very forced case, but we just want to prove that it actually works. + it('uses the estimate value rather than the precise value to select the row height', () => { + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50); + }); + + it('returns doesnt bother getting the precise count if the estimates are all below the threshold', () => { + jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3); + jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1); + + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(36); + + // this is what we really care about - we want to save on performance by not calling the counter in this case. + expect(counters[1].counter).not.toHaveBeenCalled(); + }); + + it('uses the precise count if the estimate is above the threshold, even if its below 1', () => { + // NOTE: if this fails, just change the test to use a different value besides 0.1 + expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(1); + + jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3); + jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1); + + expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50); + }); + }); + }); + + describe('computeColWidths', () => { + it('returns the configured widths if all columns set them', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: { custom: { width: 100 } }, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: { custom: { width: 200 } }, + }, + ], + 500 + ) + ).toEqual([100, 200]); + }); + + it('fills the available space if a column has no width set', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: {}, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: { custom: { width: 200 } }, + }, + ], + 500 + ) + ).toEqual([300, 200]); + }); + + it('applies minimum width when auto width would dip below it', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: { custom: { minWidth: 100 } }, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: { custom: { minWidth: 100 } }, + }, + ], + 100 + ) + ).toEqual([100, 100]); + }); + + it('should use the global column default width when nothing is set', () => { + expect( + computeColWidths( + [ + { + name: 'A', + type: FieldType.string, + values: [], + config: {}, + }, + { + name: 'B', + type: FieldType.string, + values: [], + config: {}, + }, + ], + // we have two columns but have set the table to the width of one default column. + COLUMN.DEFAULT_WIDTH + ) + ).toEqual([COLUMN.DEFAULT_WIDTH, COLUMN.DEFAULT_WIDTH]); + }); + }); + + describe('displayJsonValue', () => { + it.todo('should parse and then stringify string values'); + it.todo('should not throw for non-serializable string values'); + it.todo('should stringify non-string values'); + it.todo('should not throw for non-serializable non-string values'); }); describe('applySort', () => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 67f4bf497ed..a24a361bfda 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1,6 +1,7 @@ import { Property } from 'csstype'; import { SortColumn } from 'react-data-grid'; import tinycolor from 'tinycolor2'; +import { Count, varPreLine } from 'uwrap'; import { FieldType, @@ -25,7 +26,16 @@ import { getTextColorForAlphaBackground } from '../../../utils/colors'; import { TableCellOptions } from '../types'; import { COLUMN, TABLE } from './constants'; -import { CellColors, TableRow, ColumnTypes, FrameToRowsConverter, Comparator } from './types'; +import { + CellColors, + TableRow, + ColumnTypes, + FrameToRowsConverter, + Comparator, + TypographyCtx, + LineCounter, + LineCounterEntry, +} from './types'; /* ---------------------------- Cell calculations --------------------------- */ export type CellNumLinesCalculator = (text: string, cellWidth: number) => number; @@ -71,58 +81,190 @@ export function shouldTextWrap(field: Field): boolean { return Boolean(cellOptions?.wrapText); } -// matches characters which CSS -const spaceRegex = /[\s-]/; +/** + * @internal creates a typography context based on a font size and family. used to measure text + * and estimate size of text in cells. + */ +export function createTypographyContext(fontSize: number, fontFamily: string, letterSpacing = 0.15): TypographyCtx { + const font = `${fontSize}px ${fontFamily}`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; -export interface GetMaxWrapCellOptions { - colWidths: number[]; - avgCharWidth: number; - wrappedColIdxs: boolean[]; + ctx.letterSpacing = `${letterSpacing}px`; + ctx.font = font; + const txt = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s."; + const txtWidth = ctx.measureText(txt).width; + const avgCharWidth = txtWidth / txt.length + letterSpacing; + const { count } = varPreLine(ctx); + + return { + ctx, + font, + avgCharWidth, + estimateLines: getTextLineEstimator(avgCharWidth), + wrappedCount: wrapUwrapCount(count), + }; } /** * @internal - * loop through the fields and their values, determine which cell is going to determine the - * height of the row based on its content and width, and then return the text, index, and number of lines for that cell. */ -export function getMaxWrapCell( +export function wrapUwrapCount(count: Count): LineCounter { + return (value, width) => { + if (value == null) { + return 1; + } + + return count(String(value), width); + }; +} + +/** + * @internal returns a line counter which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth. + */ +export function getTextLineEstimator(avgCharWidth: number): LineCounter { + return (value, width) => { + if (!value) { + return -1; + } + + // we don't have string breaking enabled in the table, + // so an unbroken string is by definition a single line. + const strValue = String(value); + if (!spaceRegex.test(strValue)) { + return -1; + } + + const charsPerLine = width / avgCharWidth; + return strValue.length / charsPerLine; + }; +} + +/** + * @internal return a text line counter for every field which has wrapHeaderText enabled. + */ +export function buildHeaderLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined { + const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => { + if (field.config?.custom?.wrapHeaderText) { + acc.push(idx); + } + return acc; + }, []); + + if (wrappedColIdxs.length === 0) { + return undefined; + } + + // don't bother with estimating the line counts for the headers, because it's punishing + // when we get it wrong and there won't be that many compared to how many rows a table might contain. + return [{ counter: typographyCtx.wrappedCount, fieldIdxs: wrappedColIdxs }]; +} + +const spaceRegex = /[\s-]/; + +/** + * @internal return a text line counter for every field which has wrapHeaderText enabled. we do this once as we're rendering + * the table, and then getRowHeight uses the output of this to caluclate the height of each row. + */ +export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined { + const result: Record = {}; + let wrappedFields = 0; + + for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) { + const field = fields[fieldIdx]; + if (shouldTextWrap(field)) { + wrappedFields++; + // TODO: Pills, DataLinks, and JSON will have custom line counters here. + + // for string fields, we really want to find the longest field ahead of time to reduce the number of calls to `count`. + // calling `count` is going to get a perfectly accurate line count, but it is expensive, so we'd rather estimate the line + // count and call the counter only for the field which will take up the most space based on its + if (field.type === FieldType.string) { + result.textCounter = result.textCounter ?? { + counter: typographyCtx.wrappedCount, + estimate: typographyCtx.estimateLines, + fieldIdxs: [], + }; + result.textCounter.fieldIdxs.push(fieldIdx); + } + } + } + + if (wrappedFields === 0) { + return undefined; + } + + return Object.values(result); +} + +// in some cases, the estimator might return a value that is less than 1, but when measured by the counter, it actually +// realizes that it's a multi-line cell. to avoid this, we want to give a little buffer away from 1 before we fully trust +// the estimator to have told us that a cell is single-line. +export const SINGLE_LINE_ESTIMATE_THRESHOLD = 0.85; + +/** + * @internal + * loop through the fields and their values, determine which cell is going to determine the height of the row based + * on its content and width, and return the height in pixels of that row, with vertial padding applied. + */ +export function getRowHeight( fields: Field[], rowIdx: number, - { colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions -): { - text: string; - idx: number; - numLines: number; -} { - let maxLines = 1; - let maxLinesIdx = -1; - let maxLinesText = ''; + columnWidths: number[], + defaultHeight: number, + lineCounters?: LineCounterEntry[], + lineHeight = TABLE.LINE_HEIGHT, + verticalPadding = 0 +): number { + if (!lineCounters?.length) { + return defaultHeight; + } - // TODO: consider changing how we store this, using a record by column key instead of an array - for (let i = 0; i < colWidths.length; i++) { - if (wrappedColIdxs[i]) { - const field = fields[i]; + let maxLines = -1; + let maxValue = ''; + let maxWidth = 0; + let preciseCounter: LineCounter | undefined; + + for (const { estimate, counter, fieldIdxs } of lineCounters) { + // for some of the line counters, getting the precise count of the lines is expensive. those line counters + // set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will + // get the "true" value right before calculating the row height by hanging onto a reference to the counter fn. + const count = estimate ?? counter; + const isEstimating = estimate !== undefined; + + for (const fieldIdx of fieldIdxs) { + const field = fields[fieldIdx]; // special case: for the header, provide `-1` as the row index. - const cellTextRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx]; - - if (cellTextRaw != null) { - const cellText = String(cellTextRaw); - - if (spaceRegex.test(cellText)) { - const charsPerLine = colWidths[i] / avgCharWidth; - const approxLines = cellText.length / charsPerLine; - - if (approxLines > maxLines) { - maxLines = approxLines; - maxLinesIdx = i; - maxLinesText = cellText; - } + const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx]; + if (cellValueRaw != null) { + const colWidth = columnWidths[fieldIdx]; + const approxLines = count(cellValueRaw, colWidth); + if (approxLines > maxLines) { + maxLines = approxLines; + maxValue = cellValueRaw; + maxWidth = colWidth; + preciseCounter = isEstimating ? counter : undefined; } } } } - return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines }; + // if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust + // that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter. + if (maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) { + return defaultHeight; + } + + // if we finished this row height loop with an estimate, we need to call + // the `preciseCounter` method to get the exact line count. + if (preciseCounter !== undefined) { + maxLines = preciseCounter(maxValue, maxWidth); + } + + // we want a round number of lines for rendering + const totalHeight = Math.ceil(maxLines) * lineHeight + verticalPadding; + return Math.max(totalHeight, defaultHeight); } /** diff --git a/yarn.lock b/yarn.lock index 9ac783e535e..897c95579e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3869,7 +3869,7 @@ __metadata: typescript: "npm:5.8.3" uplot: "npm:1.6.32" uuid: "npm:11.1.0" - uwrap: "npm:0.1.1" + uwrap: "npm:0.1.2" webpack: "npm:5.97.1" peerDependencies: react: ^18.0.0 @@ -31787,10 +31787,10 @@ __metadata: languageName: node linkType: hard -"uwrap@npm:0.1.1": - version: 0.1.1 - resolution: "uwrap@npm:0.1.1" - checksum: 10/d5d02cb2f0e7fd997862913458d67e0c7fa9fd5bc1025baca9e183ac87046be9148942e59440fef8d01a34d5674c0395bb46b13e00359602ea3155b305466090 +"uwrap@npm:0.1.2": + version: 0.1.2 + resolution: "uwrap@npm:0.1.2" + checksum: 10/621d9d148d903410ef555739baca1ba84500d59a5028611bc2fca53313ffd6c9b870e25dc5cad73b41f18fe72879a3d94a6b8ebc5e141c84a94188ee1c483492 languageName: node linkType: hard From bee169d7a66f1338a108d8831385404366686af0 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:06:33 -0700 Subject: [PATCH 22/39] Geomap: Add option to toggle no-repeating (#108201) * Geomap: Add option to toggle no-repeating * Add option and apply to all basemaps * Clean up some comments * Update docs * Add tests * Fix option change handling issues * Update translations * Fix e2e test --- .../visualizations/geomap/index.md | 8 ++ .../panels-suite/geomap-layer-types.spec.ts | 2 +- .../panelcfg/x/GeomapPanelCfg_types.gen.ts | 2 + .../grafana-schema/src/veneer/common.types.ts | 2 + .../app/plugins/panel/geomap/GeomapPanel.tsx | 62 ++++++++--- .../geomap/layers/basemaps/carto.test.ts | 103 ++++++++++++++++++ .../panel/geomap/layers/basemaps/carto.ts | 3 + .../geomap/layers/basemaps/generic.test.ts | 103 ++++++++++++++++++ .../panel/geomap/layers/basemaps/generic.ts | 3 + .../panel/geomap/layers/basemaps/osm.test.ts | 67 ++++++++++++ .../panel/geomap/layers/basemaps/osm.ts | 4 +- .../plugins/panel/geomap/migrations.test.ts | 27 +++++ public/app/plugins/panel/geomap/module.tsx | 8 ++ public/app/plugins/panel/geomap/panelcfg.cue | 1 + .../app/plugins/panel/geomap/panelcfg.gen.ts | 2 + public/locales/en-US/grafana.json | 2 + 16 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts create mode 100644 public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts diff --git a/docs/sources/panels-visualizations/visualizations/geomap/index.md b/docs/sources/panels-visualizations/visualizations/geomap/index.md index fe8dbd11e65..3a10dea71d6 100644 --- a/docs/sources/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/panels-visualizations/visualizations/geomap/index.md @@ -184,6 +184,14 @@ The **Share view** option allows you to link the movement and zoom actions of mu You might need to reload the dashboard for this feature to work. {{< /admonition >}} +#### No map repeating + +The **No map repeating** option prevents the base map tiles from repeating horizontally when you pan across the world. This constrains the view to a single instance of the world map and avoids visual confusion when displaying global datasets. + +{{< admonition type="note" >}} +Enabling this option requires the map to reinitialize. +{{< /admonition >}} + ### Map layers options Geomaps support showing multiple layers. Each layer determines how you visualize geospatial data on top of the base map. diff --git a/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts b/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts index 17779dde1c9..803e2e7042d 100644 --- a/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts +++ b/e2e/old-arch/panels-suite/geomap-layer-types.spec.ts @@ -13,7 +13,7 @@ describe('Geomap layer types', () => { it('Tests changing the layer type', () => { e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } }); - cy.get('[data-testid="layer-drag-drop-list"]').should('be.visible'); + cy.get('[data-testid="layer-drag-drop-list"]').scrollIntoView().should('be.visible'); e2e.components.PanelEditor.OptionsPane.fieldLabel(MAP_LAYERS_TYPE).should('be.visible'); cy.get('[data-testid="layer-drag-drop-list"]').contains('markers'); diff --git a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts index 0a085d8ec6e..fa1e3eaf299 100644 --- a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts @@ -33,6 +33,7 @@ export interface MapViewConfig { lon?: number; maxZoom?: number; minZoom?: number; + noRepeat?: boolean; padding?: number; shared?: boolean; zoom?: number; @@ -43,6 +44,7 @@ export const defaultMapViewConfig: Partial = { id: 'zero', lat: 0, lon: 0, + noRepeat: false, zoom: 1, }; diff --git a/packages/grafana-schema/src/veneer/common.types.ts b/packages/grafana-schema/src/veneer/common.types.ts index fb3bfe18c57..7a119bdb1af 100644 --- a/packages/grafana-schema/src/veneer/common.types.ts +++ b/packages/grafana-schema/src/veneer/common.types.ts @@ -6,6 +6,8 @@ export interface MapLayerOptions extends raw.MapLayerOptions { // Custom options depending on the type config?: TConfig; filterData?: MatcherConfig; + // Disable world repetition for basemap layers + noRepeat?: boolean; } export interface DataQuery extends raw.DataQuery { diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 14399a2893d..6984228c6f7 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -2,14 +2,14 @@ import { css } from '@emotion/css'; import { Global } from '@emotion/react'; import OpenLayersMap from 'ol/Map'; import MapBrowserEvent from 'ol/MapBrowserEvent'; -import View from 'ol/View'; +import View, { ViewOptions } from 'ol/View'; import Attribution from 'ol/control/Attribution'; import ScaleLine from 'ol/control/ScaleLine'; import Zoom from 'ol/control/Zoom'; import { Coordinate } from 'ol/coordinate'; import { isEmpty } from 'ol/extent'; import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; -import { fromLonLat } from 'ol/proj'; +import { fromLonLat, transformExtent } from 'ol/proj'; import { Component, ReactNode } from 'react'; import * as React from 'react'; import { Subscription } from 'rxjs'; @@ -132,11 +132,6 @@ export class GeomapPanel extends Component { this.dataChanged(nextProps.data); } - // Options changed - if (this.props.options !== nextProps.options) { - this.optionsChanged(nextProps.options); - } - return true; // always? } @@ -148,6 +143,10 @@ export class GeomapPanel extends Component { if (this.map && this.props.data !== prevProps.data) { this.dataChanged(this.props.data); } + // Handle options changes + if (this.props.options !== prevProps.options) { + this.optionsChanged(prevProps.options, this.props.options); + } } /** This function will actually update the JSON model */ @@ -177,18 +176,29 @@ export class GeomapPanel extends Component { * * NOTE: changes to basemap and layers are handled independently */ - optionsChanged(options: Options) { - const oldOptions = this.props.options; - if (options.view !== oldOptions.view) { - const view = this.initMapView(options.view); + optionsChanged(oldOptions: Options, newOptions: Options) { + // First check if noRepeat changed - requires full map reinitialization + const noRepeatChanged = oldOptions.view?.noRepeat !== newOptions.view?.noRepeat; + if (noRepeatChanged) { + if (this.mapDiv) { + this.initMapRef(this.mapDiv); + } + // Skip other options processing + return; + } + + // Handle incremental view changes + if (oldOptions.view !== newOptions.view) { + const view = this.initMapView(newOptions.view); if (this.map && view) { this.map.setView(view); } } - if (options.controls !== oldOptions.controls) { - this.initControls(options.controls ?? { showZoom: true, showAttribution: true }); + // Handle controls changes + if (newOptions.controls !== oldOptions.controls) { + this.initControls(newOptions.controls ?? { showZoom: true, showAttribution: true }); } } @@ -234,7 +244,12 @@ export class GeomapPanel extends Component { this.byName.clear(); const layers: MapLayerState[] = []; try { - layers.push(await initLayer(this, map, options.basemap ?? DEFAULT_BASEMAP_CONFIG, true)); + // Pass noRepeat setting to basemap layer + const basemapOptions = { + ...(options.basemap ?? DEFAULT_BASEMAP_CONFIG), + noRepeat: options.view?.noRepeat ?? false, + }; + layers.push(await initLayer(this, map, basemapOptions, true)); // Default layer values if (!options.layers) { @@ -284,11 +299,24 @@ export class GeomapPanel extends Component { }; initMapView = (config: MapViewConfig): View | undefined => { - let view = new View({ + const noRepeat = config.noRepeat ?? false; + + let viewOptions: ViewOptions = { center: [0, 0], zoom: 1, - showFullExtent: true, // allows zooming so the full range is visible - }); + }; + + // Only apply constraints when no-repeat is enabled + if (noRepeat) { + // Define the world extent in EPSG:3857 (Web Mercator) + const worldExtent = [-180, -85.05112878, 180, 85.05112878]; // [minx, miny, maxx, maxy] in EPSG:4326 + const projectedExtent = transformExtent(worldExtent, 'EPSG:4326', 'EPSG:3857'); + viewOptions.extent = projectedExtent; + viewOptions.showFullExtent = false; + viewOptions.constrainOnlyCenter = false; + } + + let view = new View(viewOptions); // With shared views, all panels use the same view instance if (config.shared) { diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts new file mode 100644 index 00000000000..88e3f95d9a3 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.test.ts @@ -0,0 +1,103 @@ +import OpenLayersMap from 'ol/Map'; +import TileLayer from 'ol/layer/Tile'; +import XYZ from 'ol/source/XYZ'; + +import { EventBus, GrafanaTheme2, MapLayerOptions } from '@grafana/data'; + +import { carto, CartoConfig, LayerTheme } from './carto'; + +describe('CARTO basemap layer noRepeat functionality', () => { + let mockMap: OpenLayersMap; + let mockEventBus: EventBus; + let mockTheme: GrafanaTheme2; + + beforeEach(() => { + mockMap = {} as OpenLayersMap; + mockEventBus = {} as EventBus; + mockTheme = { isDark: false } as GrafanaTheme2; + }); + + it('should set wrapX to false when noRepeat is true', async () => { + const options: MapLayerOptions = { + name: 'Test CARTO Layer', + type: 'carto', + config: { + theme: LayerTheme.Light, + showLabels: true, + }, + noRepeat: true, + }; + + const result = await carto.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source).toBeInstanceOf(XYZ); + expect(source.getWrapX()).toBe(false); + }); + + it('should set wrapX to true when noRepeat is false', async () => { + const options: MapLayerOptions = { + name: 'Test CARTO Layer', + type: 'carto', + config: { + theme: LayerTheme.Dark, + showLabels: false, + }, + noRepeat: false, + }; + + const result = await carto.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source).toBeInstanceOf(XYZ); + expect(source.getWrapX()).toBe(true); + }); + + it('should set wrapX to true when noRepeat is undefined (defaults to false)', async () => { + const options: MapLayerOptions = { + name: 'Test CARTO Layer', + type: 'carto', + config: { + theme: LayerTheme.Auto, + showLabels: true, + }, + // noRepeat not specified + }; + + const result = await carto.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source).toBeInstanceOf(XYZ); + expect(source.getWrapX()).toBe(true); + }); + + it('should preserve theme and label settings when noRepeat is set', async () => { + const mockDarkTheme = { isDark: true } as GrafanaTheme2; + const options: MapLayerOptions = { + name: 'Test CARTO Layer', + type: 'carto', + config: { + theme: LayerTheme.Auto, // Should use dark theme from mockDarkTheme + showLabels: false, + }, + noRepeat: true, + }; + + const result = await carto.create(mockMap, options, mockEventBus, mockDarkTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source.getWrapX()).toBe(false); + + // Check that the URL reflects the dark theme without labels + const urls = source.getUrls(); + expect(urls?.[0]).toContain('dark_nolabels'); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts index 92f3c54502f..3e2572a702f 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts @@ -51,10 +51,13 @@ export const carto: MapLayerRegistryItem = { style += '_nolabels'; } const scale = window.devicePixelRatio > 1 ? '@2x' : ''; + const noRepeat = options.noRepeat ?? false; + return new TileLayer({ source: new XYZ({ attributions: `©CARTO ©OpenStreetMap contributors`, url: `https://{1-4}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}${scale}.png`, + wrapX: !noRepeat, }), }); }, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts new file mode 100644 index 00000000000..180a2be2644 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.test.ts @@ -0,0 +1,103 @@ +import OpenLayersMap from 'ol/Map'; +import TileLayer from 'ol/layer/Tile'; +import XYZ from 'ol/source/XYZ'; + +import { EventBus, GrafanaTheme2, MapLayerOptions } from '@grafana/data'; + +import { xyzTiles, XYZConfig } from './generic'; + +describe('XYZ tile layer noRepeat functionality', () => { + let mockMap: OpenLayersMap; + let mockEventBus: EventBus; + let mockTheme: GrafanaTheme2; + + beforeEach(() => { + mockMap = {} as OpenLayersMap; + mockEventBus = {} as EventBus; + mockTheme = {} as GrafanaTheme2; + }); + + it('should set wrapX to false when noRepeat is true', async () => { + const options: MapLayerOptions = { + name: 'Test Layer', + type: 'xyz', + config: { + url: 'https://example.com/{z}/{x}/{y}.png', + attribution: 'Test Attribution', + }, + noRepeat: true, + }; + + const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source).toBeInstanceOf(XYZ); + expect(source.getWrapX()).toBe(false); + }); + + it('should set wrapX to true when noRepeat is false', async () => { + const options: MapLayerOptions = { + name: 'Test Layer', + type: 'xyz', + config: { + url: 'https://example.com/{z}/{x}/{y}.png', + attribution: 'Test Attribution', + }, + noRepeat: false, + }; + + const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source).toBeInstanceOf(XYZ); + expect(source.getWrapX()).toBe(true); + }); + + it('should set wrapX to true when noRepeat is undefined (defaults to false)', async () => { + const options: MapLayerOptions = { + name: 'Test Layer', + type: 'xyz', + config: { + url: 'https://example.com/{z}/{x}/{y}.png', + attribution: 'Test Attribution', + }, + // noRepeat not specified + }; + + const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as XYZ; + expect(source).toBeInstanceOf(XYZ); + expect(source.getWrapX()).toBe(true); + }); + + it('should preserve other layer properties when noRepeat is set', async () => { + const options: MapLayerOptions = { + name: 'Test Layer', + type: 'xyz', + config: { + url: 'https://example.com/{z}/{x}/{y}.png', + attribution: 'Test Attribution', + minZoom: 2, + maxZoom: 18, + }, + noRepeat: true, + }; + + const result = await xyzTiles.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + expect(layer.getMinZoom()).toBe(2); + expect(layer.getMaxZoom()).toBe(18); + + const source = (layer as TileLayer).getSource() as XYZ; + expect(source.getWrapX()).toBe(false); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts index 2ed2a022037..f3381902e26 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts @@ -35,10 +35,13 @@ export const xyzTiles: MapLayerRegistryItem = { cfg.url = defaultXYZConfig.url; cfg.attribution = cfg.attribution ?? defaultXYZConfig.attribution; } + const noRepeat = options.noRepeat ?? false; + return new TileLayer({ source: new XYZ({ url: cfg.url, attributions: cfg.attribution, // singular? + wrapX: !noRepeat, }), minZoom: cfg.minZoom, maxZoom: cfg.maxZoom, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts b/public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts new file mode 100644 index 00000000000..19a62533b2a --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/basemaps/osm.test.ts @@ -0,0 +1,67 @@ +import OpenLayersMap from 'ol/Map'; +import TileLayer from 'ol/layer/Tile'; +import OSM from 'ol/source/OSM'; + +import { EventBus, MapLayerOptions, GrafanaTheme2 } from '@grafana/data'; + +import { standard } from './osm'; + +describe('OSM layer noRepeat functionality', () => { + let mockMap: OpenLayersMap; + let mockEventBus: EventBus; + let mockTheme: GrafanaTheme2; + + beforeEach(() => { + mockMap = {} as OpenLayersMap; + mockEventBus = {} as EventBus; + mockTheme = {} as GrafanaTheme2; + }); + + it('should set wrapX to false when noRepeat is true', async () => { + const options: MapLayerOptions = { + name: 'Test OSM Layer', + type: 'osm-standard', + noRepeat: true, + }; + + const result = await standard.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as OSM; + expect(source).toBeInstanceOf(OSM); + expect(source.getWrapX()).toBe(false); + }); + + it('should set wrapX to true when noRepeat is false', async () => { + const options: MapLayerOptions = { + name: 'Test OSM Layer', + type: 'osm-standard', + noRepeat: false, + }; + + const result = await standard.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as OSM; + expect(source).toBeInstanceOf(OSM); + expect(source.getWrapX()).toBe(true); + }); + + it('should set wrapX to true when noRepeat is undefined (defaults to false)', async () => { + const options: MapLayerOptions = { + name: 'Test OSM Layer', + type: 'osm-standard', + // noRepeat not specified + }; + + const result = await standard.create(mockMap, options, mockEventBus, mockTheme); + const layer = result.init(); + + expect(layer).toBeInstanceOf(TileLayer); + const source = (layer as TileLayer).getSource() as OSM; + expect(source).toBeInstanceOf(OSM); + expect(source.getWrapX()).toBe(true); + }); +}); diff --git a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts index c644702b1ca..078ddafff43 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts @@ -16,8 +16,10 @@ export const standard: MapLayerRegistryItem = { */ create: async (map: OpenLayersMap, options: MapLayerOptions, eventBus: EventBus) => ({ init: () => { + const noRepeat = options.noRepeat ?? false; + return new TileLayer({ - source: new OSM(), + source: new OSM({ wrapX: !noRepeat }), }); }, }), diff --git a/public/app/plugins/panel/geomap/migrations.test.ts b/public/app/plugins/panel/geomap/migrations.test.ts index eda219ea9ec..6cf672211dd 100644 --- a/public/app/plugins/panel/geomap/migrations.test.ts +++ b/public/app/plugins/panel/geomap/migrations.test.ts @@ -248,4 +248,31 @@ describe('geomap migrations', () => { } `); }); + it('should handle migration when noRepeat is not set', () => { + const panel = { + id: 2, + type: 'geomap', + options: { + view: { + id: 'coords', + zoom: 5, + }, + layers: [ + { + type: 'markers', + config: { + showLegend: false, + }, + }, + ], + }, + pluginVersion: '8.2.0', + } as PanelModel; + + panel.options = mapMigrationHandler(panel); + + expect(panel.options.view.noRepeat).toBeUndefined(); + expect(panel.options.view.id).toBe('coords'); + expect(panel.options.view.zoom).toBe(5); + }); }); diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index f8c7825f896..7573f9317aa 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -42,6 +42,14 @@ export const plugin = new PanelPlugin(GeomapPanel) defaultValue: defaultMapViewConfig.shared, }); + builder.addBooleanSwitch({ + category, + path: 'view.noRepeat', + name: t('geomap.name-no-repeat', 'No map repeating'), + description: t('geomap.description-no-repeat', 'Prevent the map from repeating horizontally'), + defaultValue: false, + }); + // eslint-disable-next-line const state = context.instanceState as GeomapInstanceState; if (!state?.layers) { diff --git a/public/app/plugins/panel/geomap/panelcfg.cue b/public/app/plugins/panel/geomap/panelcfg.cue index 67d12b9293a..7384ec581e5 100644 --- a/public/app/plugins/panel/geomap/panelcfg.cue +++ b/public/app/plugins/panel/geomap/panelcfg.cue @@ -45,6 +45,7 @@ composableKinds: PanelCfg: { lastOnly?: bool layer?: string shared?: bool + noRepeat?: bool | *false } @cuetsy(kind="interface") ControlsOptions: { diff --git a/public/app/plugins/panel/geomap/panelcfg.gen.ts b/public/app/plugins/panel/geomap/panelcfg.gen.ts index 7e4288bf050..cffd8e01e13 100644 --- a/public/app/plugins/panel/geomap/panelcfg.gen.ts +++ b/public/app/plugins/panel/geomap/panelcfg.gen.ts @@ -31,6 +31,7 @@ export interface MapViewConfig { lon?: number; maxZoom?: number; minZoom?: number; + noRepeat?: boolean; padding?: number; shared?: boolean; zoom?: number; @@ -41,6 +42,7 @@ export const defaultMapViewConfig: Partial = { id: 'zero', lat: 0, lon: 0, + noRepeat: false, zoom: 1, }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d0aa51d51e2..f9f5d8553c8 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7512,6 +7512,7 @@ }, "description-initial-view": "This location will show when the panel first loads.", "description-mouse-wheel-zoom": "Enable zoom control via mouse wheel", + "description-no-repeat": "Prevent the map from repeating horizontally", "description-share-view": "Use the same view across multiple panels. Note: this may require a dashboard reload.", "description-show-attribution": "Show the map source attribution info in the lower right", "description-show-debug": "Show map info", @@ -7571,6 +7572,7 @@ }, "name-initial-view": "Initial view", "name-mouse-wheel-zoom": "Mouse wheel zoom", + "name-no-repeat": "No map repeating", "name-share-view": "Share view", "name-show-attribution": "Show attribution", "name-show-debug": "Show debug", From 4392cea75a89239b1b56b9f094aa2fb405a5e0b3 Mon Sep 17 00:00:00 2001 From: Russ <8377044+rdubrock@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:23:41 -0800 Subject: [PATCH 23/39] chore: add an option to hide the metrics browser in a PromQueryField (#108718) --- .../src/components/PromQueryField.test.tsx | 13 ++++++++ .../src/components/PromQueryField.tsx | 32 +++++++++++-------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx index d3fc7b80360..62fbdde8232 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx @@ -80,6 +80,19 @@ describe('PromQueryField', () => { expect(bcButton).toBeDisabled(); }); + it('renders no metrics chooser if hidden by props', async () => { + const props = { + ...defaultProps, + hideMetricsBrowser: true, + }; + const queryField = render(); + + // wait for component to render + await screen.findByTestId('dummy-code-input'); + + expect(queryField.queryByRole('button')).not.toBeInTheDocument(); + }); + it('renders an initial hint if no data and initial hint provided', async () => { const props = defaultProps; props.datasource.lookupsDisabled = true; diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index 289b206b7d2..1a6f95bba50 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -25,6 +25,7 @@ import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWr interface PromQueryFieldProps extends QueryEditorProps { ExtraFieldElement?: ReactNode; + hideMetricsBrowser?: boolean; 'data-testid'?: string; } @@ -40,6 +41,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => { range, onChange, onRunQuery, + hideMetricsBrowser = false, } = props; const theme = useTheme2(); @@ -111,20 +113,22 @@ export const PromQueryField = (props: PromQueryFieldProps) => { className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1" data-testid={props['data-testid']} > - + {!hideMetricsBrowser && ( + + )}
Date: Tue, 29 Jul 2025 02:52:27 -0500 Subject: [PATCH 24/39] Dashboards: Move to integration tests (#108734) --- .../database/database_folder_test.go | 219 ---- .../dashboard_service_integration_test.go | 1092 ----------------- .../service/dashboard_service_test.go | 5 + .../api/dashboards/api_dashboards_test.go | 618 ++++++++-- 4 files changed, 545 insertions(+), 1389 deletions(-) delete mode 100644 pkg/services/dashboards/service/dashboard_service_integration_test.go diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index f41ffe9a11f..dfe02a39416 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -2,36 +2,20 @@ package database import ( "context" - "errors" - "fmt" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" ) var testFeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch) @@ -230,197 +214,6 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) } -func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - // the maximux nested folder hierarchy starting from parent down to subfolders - nestedFolders := make([]*folder.Folder, 0, folder.MaxNestedFolderDepth+1) - - var sqlStore db.DB - var cfg *setting.Cfg - const ( - dashInRootTitle = "dashboard in root" - dashInParentTitle = "dashboard in parent" - dashInSubfolderTitle = "dashboard in subfolder" - ) - var viewer *user.SignedInUser - - setup := func() { - sqlStore, cfg = db.InitTestDBWithCfg(t) - cfg.AutoAssignOrg = true - cfg.AutoAssignOrgId = 1 - cfg.AutoAssignOrgRole = string(org.RoleViewer) - - tracer := tracing.InitializeTracerForTest() - quotaService := quotatest.New(false, nil) - - // enable nested folders so that the folder table is populated for all the tests - features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) - - var err error - dashboardWriteStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - - orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService( - sqlStore, orgService, cfg, nil, nil, tracer, - quotaService, supportbundlestest.NewFakeBundleService(), - ) - require.NoError(t, err) - - usr := createUser(t, usrSvc, orgService, "viewer", false) - viewer = &user.SignedInUser{ - UserID: usr.ID, - OrgID: usr.OrgID, - OrgRole: org.RoleViewer, - } - - // create admin user in the same org - currentUserCmd := user.CreateUserCommand{Login: "admin", Email: "admin@test.com", Name: "an admin", IsAdmin: false, OrgID: viewer.OrgID} - u, err := usrSvc.Create(context.Background(), ¤tUserCmd) - require.NoError(t, err) - admin := user.SignedInUser{ - UserID: u.ID, - OrgID: u.OrgID, - OrgRole: org.RoleAdmin, - Permissions: map[int64]map[string][]string{u.OrgID: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{ - { - Action: dashboards.ActionFoldersCreate, - Scope: dashboards.ScopeFoldersAll, - }}), - }, - } - require.NotEqual(t, viewer.UserID, admin.UserID) - - folderStore := folderimpl.ProvideStore(sqlStore) - folderSvc := folderimpl.ProvideService( - folderStore, mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), - nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService(), apiserver.WithoutRestConfig) - - parentUID := "" - for i := 0; ; i++ { - uid := fmt.Sprintf("f%d", i) - f, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ - UID: uid, - OrgID: admin.OrgID, - Title: uid, - SignedInUser: &admin, - ParentUID: parentUID, - }) - if err != nil { - if errors.Is(err, folder.ErrMaximumDepthReached) { - break - } - - t.Log("unexpected error", "error", err) - t.Fail() - } - - nestedFolders = append(nestedFolders, f) - - parentUID = f.UID - } - require.LessOrEqual(t, 2, len(nestedFolders)) - - saveDashboardCmd := dashboards.SaveDashboardCommand{ - UserID: admin.UserID, - OrgID: admin.OrgID, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": dashInRootTitle, - }), - } - _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) - require.NoError(t, err) - - saveDashboardCmd = dashboards.SaveDashboardCommand{ - UserID: admin.UserID, - OrgID: admin.OrgID, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": dashInParentTitle, - }), - FolderUID: nestedFolders[0].UID, - } - _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) - require.NoError(t, err) - - saveDashboardCmd = dashboards.SaveDashboardCommand{ - UserID: admin.UserID, - OrgID: admin.OrgID, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": dashInSubfolderTitle, - }), - FolderUID: nestedFolders[1].UID, - } - _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) - require.NoError(t, err) - } - - setup() - - nestedFolderTitles := make([]string, 0, len(nestedFolders)) - for _, f := range nestedFolders { - nestedFolderTitles = append(nestedFolderTitles, f.Title) - } - - testCases := []struct { - desc string - features featuremgmt.FeatureToggles - permissions map[string][]string - expectedTitles []string - }{ - { - desc: "it should not return folder if ACL is not set for parent folder", - features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), - permissions: nil, - expectedTitles: nil, - }, - { - desc: "it should not return subfolder if nested folders are disabled and the user has permission to read folders under parent folder", - features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), - permissions: map[string][]string{ - dashboards.ActionFoldersRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, - }, - expectedTitles: []string{nestedFolders[0].Title}, - }, - { - desc: "it should return subfolder if nested folders are enabled and the user has permission to read folders under parent folder", - features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch, featuremgmt.FlagNestedFolders), - permissions: map[string][]string{ - dashboards.ActionFoldersRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, - }, - expectedTitles: nestedFolderTitles, - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - dashboardReadStore, err := ProvideDashboardStore(sqlStore, cfg, tc.features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - - viewer.Permissions = map[int64]map[string][]string{viewer.OrgID: tc.permissions} - actest.AddUserPermissionToDB(t, sqlStore, viewer) - - query := &dashboards.FindPersistedDashboardsQuery{ - SignedInUser: viewer, - OrgId: viewer.OrgID, - } - - res, err := testSearchDashboards(dashboardReadStore, query) - require.NoError(t, err) - - require.Equal(t, len(tc.expectedTitles), len(res)) - for i, tlt := range tc.expectedTitles { - assert.Equal(t, tlt, res[i].Title) - } - }) - } -} - func moveDashboard(t *testing.T, dashboardStore dashboards.Store, orgId int64, dashboard *simplejson.Json, newFolderId int64, newFolderUID string) *dashboards.Dashboard { t.Helper() @@ -437,15 +230,3 @@ func moveDashboard(t *testing.T, dashboardStore dashboards.Store, orgId int64, d return dash } - -func createUser(t *testing.T, userSrv user.Service, orgSrv org.Service, name string, isAdmin bool) user.User { - t.Helper() - - o, err := orgSrv.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: fmt.Sprintf("test org %d", time.Now().UnixNano())}) - require.NoError(t, err) - - currentUserCmd := user.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin, OrgID: o.ID} - currentUser, err := userSrv.Create(context.Background(), ¤tUserCmd) - require.NoError(t, err) - return *currentUser -} diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go deleted file mode 100644 index 38c11736a1b..00000000000 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ /dev/null @@ -1,1092 +0,0 @@ -package service - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/kvstore" - "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" - "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/client" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/publicdashboards" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/tag/tagimpl" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - "github.com/grafana/grafana/pkg/tests/testsuite" -) - -const testOrgID int64 = 1 - -func TestMain(m *testing.M) { - testsuite.Run(m) -} - -func TestIntegrationIntegratedDashboardService(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) { - // Basic validation tests - - permissionScenario(t, "When saving a dashboard with non-existing id", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": float64(123412321), - "title": "Expect error", - }), - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardNotFound, err) - }) - - // Given other organization - - t.Run("Given organization B", func(t *testing.T) { - const otherOrgId int64 = 2 - - permissionScenario(t, "When creating a dashboard with same id as dashboard in organization A", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: otherOrgId, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "title": "Expect error", - }), - Overwrite: false, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardNotFound, err) - }) - - permissionScenario(t, "When creating a dashboard with same uid as dashboard in organization A, it should create a new dashboard in org B", func(t *testing.T, sc *permissionScenarioContext) { - const otherOrgId int64 = 2 - cmd := dashboards.SaveDashboardCommand{ - OrgID: otherOrgId, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Dash with existing uid in other org", - }), - Overwrite: false, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - OrgID: otherOrgId, - UID: sc.savedDashInFolder.UID, - }) - require.NoError(t, err) - }) - }) - - t.Run("Given user has permission to save", func(t *testing.T) { - t.Run("and overwrite flag is set to false", func(t *testing.T) { - const shouldOverwrite = false - - permissionScenario(t, "When creating a dashboard in General folder with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a dashboard in other folder with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInGeneralFolder.Title, - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a folder with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID) - assert.True(t, res.IsFolder) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When saving a dashboard without id and uid and unique title in folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dash without id and uid", - }), - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - assert.Greater(t, res.ID, int64(0)) - assert.NotEmpty(t, res.UID) - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When saving a dashboard when dashboard id is zero ", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": 0, - "title": "Dash with zero id", - }), - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When saving a dashboard in non-existing folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Expect error", - }), - FolderUID: "123412321", - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrFolderNotFound, err) - }) - - permissionScenario(t, "When updating an existing dashboard by id without current version", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "test dash 23", - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) - }) - - permissionScenario(t, "When updating an existing dashboard by id with current version", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "Updated title", - "version": sc.savedDashInGeneralFolder.Version, - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInGeneralFolder.ID, - OrgID: cmd.OrgID, - }) - - require.NoError(t, err) - }) - - permissionScenario(t, "When updating an existing dashboard by uid without current version", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "test dash 23", - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err) - }) - - permissionScenario(t, "When updating an existing dashboard by uid with current version", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Updated title", - "version": sc.savedDashInFolder.Version, - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - FolderUID: sc.savedDashInFolder.FolderUID, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInGeneralFolder.Title, - }), - FolderUID: sc.savedDashInGeneralFolder.FolderUID, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a folder with same name as existing folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedFolder.Title, - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NoError(t, err) - }) - }) - - t.Run("and overwrite flag is set to true", func(t *testing.T) { - const shouldOverwrite = true - - permissionScenario(t, "When updating an existing dashboard by id without current version", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInGeneralFolder.ID, - "title": "Updated title", - }), - FolderUID: sc.savedFolder.UID, - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInGeneralFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When updating an existing dashboard by uid without current version", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Updated title", - }), - FolderUID: "", - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When updating uid for existing dashboard using id", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "uid": "new-uid", - "title": sc.savedDashInFolder.Title, - }), - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - assert.Equal(t, sc.savedDashInFolder.ID, res.ID) - assert.Equal(t, "new-uid", res.UID) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: sc.savedDashInFolder.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When updating uid to an existing uid for existing dashboard using id", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "uid": sc.savedDashInGeneralFolder.UID, - "title": sc.savedDashInFolder.Title, - }), - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err) - }) - - permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) { - t.Skip() - - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInFolder.Title, - }), - FolderUID: sc.savedDashInFolder.FolderUID, - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - assert.Equal(t, sc.savedDashInFolder.ID, res.ID) - assert.Equal(t, sc.savedDashInFolder.UID, res.UID) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) { - t.Skip() - - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": sc.savedDashInGeneralFolder.Title, - }), - FolderUID: sc.savedDashInGeneralFolder.FolderUID, - Overwrite: shouldOverwrite, - } - - res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NotNil(t, res) - assert.Equal(t, sc.savedDashInGeneralFolder.ID, res.ID) - assert.Equal(t, sc.savedDashInGeneralFolder.UID, res.UID) - - _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{ - ID: res.ID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - }) - - permissionScenario(t, "When updating existing folder to a dashboard using id", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedFolder.ID, - "title": "new title", - }), - IsFolder: false, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) - - permissionScenario(t, "When updating existing dashboard to a folder using id", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": sc.savedDashInFolder.ID, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) - - permissionScenario(t, "When updating existing folder to a dashboard using uid", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedFolder.UID, - "title": "new title", - }), - IsFolder: false, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) - - permissionScenario(t, "When updating existing dashboard to a folder using uid", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "new folder title", - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err) - }) - - permissionScenario(t, "When updating existing folder to a dashboard using title", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": sc.savedFolder.Title, - }), - IsFolder: false, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NoError(t, err) - }) - - permissionScenario(t, "When updating existing dashboard to a folder using title", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: 1, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": sc.savedDashInGeneralFolder.Title, - }), - IsFolder: true, - Overwrite: shouldOverwrite, - } - - _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil) - require.NoError(t, err) - }) - }) - }) - }) -} - -func TestIntegrationDashboardServicePermissions(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) { - permissionScenario(t, "When creating a new dashboard in the General folder, requires create permissions scoped to the general folder", - func(t *testing.T, sc *permissionScenarioContext) { - sqlStore := db.InitTestDB(t) - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dash", - }), - UserID: 10000, - Overwrite: true, - } - - permissions := map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsAll}, - }, - } - _, err := callSaveWithResult(t, cmd, sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sqlStore, permissions) - assert.Nil(t, err) - }) - - permissionScenario(t, "When creating a new dashboard in other folder, requires create permissions scoped to the other folder", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "title": "Dash", - }), - FolderUID: sc.otherSavedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - permissions := map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("different_folder_uid")}, - }, - } - _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Nil(t, err) - }) - - permissionScenario(t, "When creating a new dashboard by existing UID in folder, requires write permissions on the existing dashboard", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "New dash", - }), - FolderUID: sc.savedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - permissions := map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID("different_dash_uid")}, - }, - } - _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Nil(t, err) - }) - - permissionScenario(t, "When moving a dashboard by existing uid to other folder from General folder, requires dashboard creation permissions on the destination folder and write access to the dashboard", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInGeneralFolder.UID, - "title": "Dash", - }), - FolderUID: sc.otherSavedFolder.UID, - UserID: 10000, - Overwrite: true, - } - - // Perms to write dashboard but not create dashboards in the destination folder - permissions := map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInGeneralFolder.UID)}, - }, - } - _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - // Perms to create dashboards in the destination folder but not write the dashboard - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - // Perms to write dashboard and create dashboards in the destination folder - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInGeneralFolder.UID)}, - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Nil(t, err) - }) - - permissionScenario(t, "When moving a dashboard by existing uid to the General folder from other folder, requires dashboard creation permissions on the general folder and write access to the dashboard", func(t *testing.T, sc *permissionScenarioContext) { - cmd := dashboards.SaveDashboardCommand{ - OrgID: testOrgID, - Dashboard: simplejson.NewFromAny(map[string]any{ - "uid": sc.savedDashInFolder.UID, - "title": "Dash", - }), - FolderUID: "", - UserID: 10000, - Overwrite: true, - } - - // Perms to write dashboard but not create dashboards in the destination folder - permissions := map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)}, - }, - } - _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - // Perms to create dashboards in the destination folder but not write the dashboard - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err) - - // Perms to write dashboard and create dashboards in the destination folder - permissions = map[int64]map[string][]string{ - testOrgID: { - dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)}, - dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)}, - }, - } - _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions) - assert.NoError(t, err) - }) - }) -} - -type permissionScenarioContext struct { - sqlStore db.DB - dashboardStore dashboards.Store - savedFolder *dashboards.Dashboard - savedDashInFolder *dashboards.Dashboard - otherSavedFolder *dashboards.Dashboard - savedDashInGeneralFolder *dashboards.Dashboard -} - -type permissionScenarioFunc func(t *testing.T, sc *permissionScenarioContext) - -func permissionScenario(t *testing.T, desc string, fn permissionScenarioFunc) { - t.Helper() - - t.Run(desc, func(t *testing.T) { - features := featuremgmt.WithFeatures() - cfg := setting.NewCfg() - sqlStore := db.InitTestDB(t) - quotaService := quotatest.New(false, nil) - ac := actest.FakeAccessControl{ExpectedEvaluate: true} - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - folderPermissions := accesscontrolmock.NewMockedPermissionsService() - folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - tracer := tracing.InitializeTracerForTest() - publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - folderStore2 := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService( - folderStore2, - actest.FakeAccessControl{ExpectedEvaluate: true}, - bus.ProvideBus(tracer), - dashboardStore, - folderStore, - nil, - sqlStore, - features, - supportbundlestest.NewFakeBundleService(), - publicDashboardFakeService, - cfg, - nil, - tracer, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - apiserver.WithoutRestConfig, - ) - dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() - dashboardService, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - featuremgmt.WithFeatures(), - folderPermissions, - ac, - actest.FakeService{}, - folderService, - nil, - client.MockTestRestConfig{}, - nil, - quotaService, - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - dashboardService.RegisterDashboardPermissions(dashboardPermissions) - require.NoError(t, err) - - savedFolder := saveTestFolder(t, "Saved folder", testOrgID, sqlStore) - savedDashInFolder := saveTestDashboard(t, "Saved dash in folder", testOrgID, savedFolder.UID, sqlStore) - saveTestDashboard(t, "Other saved dash in folder", testOrgID, savedFolder.UID, sqlStore) - savedDashInGeneralFolder := saveTestDashboard(t, "Saved dashboard in general folder", testOrgID, "", sqlStore) - otherSavedFolder := saveTestFolder(t, "Other saved folder", testOrgID, sqlStore) - - require.Equal(t, "Saved folder", savedFolder.Title) - require.Equal(t, "saved-folder", savedFolder.Slug) - require.NotEqual(t, int64(0), savedFolder.ID) - require.True(t, savedFolder.IsFolder) - require.NotEmpty(t, savedFolder.UID) - - require.Equal(t, "Saved dash in folder", savedDashInFolder.Title) - require.Equal(t, "saved-dash-in-folder", savedDashInFolder.Slug) - require.NotEqual(t, int64(0), savedDashInFolder.ID) - require.False(t, savedDashInFolder.IsFolder) - require.NotEmpty(t, savedDashInFolder.UID) - - sc := &permissionScenarioContext{ - sqlStore: sqlStore, - savedDashInFolder: savedDashInFolder, - otherSavedFolder: otherSavedFolder, - savedDashInGeneralFolder: savedDashInGeneralFolder, - savedFolder: savedFolder, - dashboardStore: dashboardStore, - } - - fn(t, sc) - }) -} - -func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB, permissions map[int64]map[string][]string) (*dashboards.Dashboard, error) { - t.Helper() - - features := featuremgmt.WithFeatures() - dto := toSaveDashboardDto(cmd) - var ac accesscontrol.AccessControl - ac = actest.FakeAccessControl{ExpectedEvaluate: true} - if permissions != nil { - dto.User = &user.SignedInUser{UserID: cmd.UserID, OrgID: testOrgID, Permissions: permissions} - ac = acimpl.ProvideAccessControl(features) - } - cfg := setting.NewCfg() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - folderPermissions := accesscontrolmock.NewMockedPermissionsService() - folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - tracer := tracing.InitializeTracerForTest() - publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - folderStore2 := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService( - folderStore2, - actest.FakeAccessControl{ExpectedEvaluate: true}, - bus.ProvideBus(tracer), - dashboardStore, - folderStore, - nil, - sqlStore, - features, - supportbundlestest.NewFakeBundleService(), - publicDashboardFakeService, - cfg, - nil, - tracer, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - apiserver.WithoutRestConfig, - ) - dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() - dashboardPermissions.On("SetPermissions", - mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - featuremgmt.WithFeatures(), - folderPermissions, - ac, - actest.FakeService{}, - folderService, - nil, - client.MockTestRestConfig{}, - nil, - quotaService, - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - service.RegisterDashboardPermissions(dashboardPermissions) - return service.SaveDashboard(context.Background(), &dto, false) -} - -func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard { - t.Helper() - - cmd := dashboards.SaveDashboardCommand{ - OrgID: orgID, - FolderUID: folderUID, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": title, - }), - } - - dto := dashboards.SaveDashboardDTO{ - OrgID: orgID, - Dashboard: cmd.GetDashboardModel(), - User: &user.SignedInUser{ - UserID: 1, - OrgRole: org.RoleAdmin, - }, - } - features := featuremgmt.WithFeatures() - cfg := setting.NewCfg() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() - dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - tracer := tracing.InitializeTracerForTest() - publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - folderStore2 := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService(folderStore2, - actest.FakeAccessControl{ExpectedEvaluate: true}, - bus.ProvideBus(tracer), - dashboardStore, - folderStore, - nil, - sqlStore, - features, - supportbundlestest.NewFakeBundleService(), - publicDashboardFakeService, - cfg, - nil, - tracer, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - apiserver.WithoutRestConfig, - ) - service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - features, - accesscontrolmock.NewMockedPermissionsService(), - actest.FakeAccessControl{ExpectedEvaluate: true}, - actest.FakeService{}, - folderService, - nil, - client.MockTestRestConfig{}, - nil, - quotaService, - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - service.RegisterDashboardPermissions(dashboardPermissions) - res, err := service.SaveDashboard(context.Background(), &dto, false) - - require.NoError(t, err) - - return res -} - -func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *dashboards.Dashboard { - t.Helper() - cmd := dashboards.SaveDashboardCommand{ - OrgID: orgID, - FolderUID: "", - IsFolder: true, - Dashboard: simplejson.NewFromAny(map[string]any{ - "id": nil, - "title": title, - }), - } - - dto := dashboards.SaveDashboardDTO{ - OrgID: orgID, - Dashboard: cmd.GetDashboardModel(), - User: &user.SignedInUser{ - OrgID: orgID, - UserID: 1, - OrgRole: org.RoleAdmin, - Permissions: map[int64]map[string][]string{ - orgID: {dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersAll}, dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsAll}}, - }, - }, - } - - features := featuremgmt.WithFeatures() - cfg := setting.NewCfg() - quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore)) - require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - folderPermissions := accesscontrolmock.NewMockedPermissionsService() - tracer := tracing.InitializeTracerForTest() - publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t) - folderStore2 := folderimpl.ProvideStore(sqlStore) - folderService := folderimpl.ProvideService(folderStore2, - actest.FakeAccessControl{ExpectedEvaluate: true}, - bus.ProvideBus(tracer), - dashboardStore, - folderStore, - nil, - sqlStore, - features, - supportbundlestest.NewFakeBundleService(), - publicDashboardFakeService, - cfg, - nil, - tracer, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - apiserver.WithoutRestConfig, - ) - folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - service, err := ProvideDashboardServiceImpl( - cfg, dashboardStore, folderStore, - featuremgmt.WithFeatures(), - folderPermissions, - actest.FakeAccessControl{ExpectedEvaluate: true}, - actest.FakeService{}, - folderService, - nil, - client.MockTestRestConfig{}, - nil, - quotaService, - nil, - nil, - nil, - dualwrite.ProvideTestService(), - sort.ProvideService(), - serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()), - kvstore.NewFakeKVStore(), - ) - require.NoError(t, err) - service.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService()) - res, err := service.SaveDashboard(context.Background(), &dto, false) - require.NoError(t, err) - - return res -} - -func toSaveDashboardDto(cmd dashboards.SaveDashboardCommand) dashboards.SaveDashboardDTO { - dash := (&cmd).GetDashboardModel() - - return dashboards.SaveDashboardDTO{ - Dashboard: dash, - Message: cmd.Message, - OrgID: cmd.OrgID, - User: &user.SignedInUser{UserID: cmd.UserID}, - Overwrite: cmd.Overwrite, - } -} diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 68da3b789cc..34a9279bec4 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -47,8 +47,13 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/search" + "github.com/grafana/grafana/pkg/tests/testsuite" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestDashboardService(t *testing.T) { t.Run("Dashboard service tests", func(t *testing.T) { fakeStore := dashboards.FakeDashboardStore{} diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 37a1f195044..0d87e0c6f00 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -21,8 +21,11 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util" @@ -32,21 +35,243 @@ func TestMain(m *testing.M) { testsuite.Run(m) } +func TestIntegrationDashboardServiceValidation(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + }) + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + orgPayload := map[string]interface{}{ + "name": "Org B", + } + orgPayloadBytes, err := json.Marshal(orgPayload) + require.NoError(t, err) + + orgURL := fmt.Sprintf("http://admin:admin@%s/api/orgs", grafanaListedAddr) + orgResp, err := http.Post(orgURL, "application/json", bytes.NewBuffer(orgPayloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, orgResp.StatusCode) + err = orgResp.Body.Close() + require.NoError(t, err) + + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Login: "admin-org2", + Password: "admin", + IsAdmin: true, + OrgID: 2, + }) + + savedFolder := createFolder(t, grafanaListedAddr, "Saved folder") + savedDashInFolder := createDashboard(t, grafanaListedAddr, "Saved dash in folder", savedFolder.ID, savedFolder.UID) // nolint:staticcheck + savedDashInGeneralFolder := createDashboard(t, grafanaListedAddr, "Saved dashboard in general folder", 0, "") + + t.Run("When saving a dashboard with non-existing id in org A", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "id": 123412321, + "title": "Expect error", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard with existing ID from org A in org B", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin-org2", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "id": savedDashInFolder.ID, // nolint:staticcheck + "title": "Expect error", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard with same UID in org A and org B, should be okay", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin-org2", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder.UID, + "title": "Saved dash in folder", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When creating a dashboard in General folder with same name as dashboard in other folder", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Saved dash in folder", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + t.Run("When creating a dashboard in other folder with same name as dashboard in General folder", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder, + "title": "Dash with existing uid in other org", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When creating a folder with same name as dashboard in other folder", func(t *testing.T) { + f := createFolder(t, grafanaListedAddr, "Saved dashboard in general folder") + require.Equal(t, f.Title, "Saved dashboard in general folder") + }) + + t.Run("When saving a dashboard without id and uid and unique title in folder", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Unique", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard with id 0", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "id": 0, + "title": "Dash with zero id", + }, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard in non-existing folder", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "no folder", + }, + "folderUid": "non-existing-folder", + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard with incorrect version but no overwrite", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder.UID, + "version": 1, + }, + "folderUid": savedDashInFolder.FolderUID, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard with current version and overwrite is true", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder.UID, + "version": savedDashInFolder.Version, + "title": "Saved dash in folder", + }, + "folderUid": savedDashInFolder.FolderUID, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When saving a dashboard with no version set and title set to a folder title", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder.UID, + "title": "Saved folder", + }, + "folderUid": savedDashInFolder.FolderUID, + "overwrite": true, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When updating uid with id", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "id": savedDashInFolder.ID, // nolint:staticcheck + "uid": "new-uid", + "title": "Updated title", + }, + "folderUid": savedDashInFolder.FolderUID, + "overwrite": true, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + t.Run("When updating uid with a dashboard already using that uid", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "id": savedDashInFolder.ID, // nolint:staticcheck + "uid": savedDashInGeneralFolder.UID, + "title": "Updated title", + }, + "folderUid": savedDashInFolder.FolderUID, + "overwrite": true, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When trying to update to a folder", func(t *testing.T) { + resp, err := postDashboard(t, grafanaListedAddr, "admin", "admin", map[string]interface{}{ + "dashboard": map[string]interface{}{ + "id": savedDashInFolder.ID, // nolint:staticcheck + "uid": savedDashInFolder.UID, + "title": "Updated title", + }, + "isFolder": true, + "folderUid": savedDashInFolder.FolderUID, + "overwrite": true, + }) + require.NoError(t, err) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) +} + func TestIntegrationDashboardQuota(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - testDashboardQuota(t, []string{}) -} - -func TestIntegrationDashboardQuotaK8s(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - testDashboardQuota(t, []string{featuremgmt.FlagKubernetesClientDashboardsFolders}) -} - -func testDashboardQuota(t *testing.T, featureToggles []string) { // enable quota and set low dashboard quota // Setup Grafana and its Database dashboardQuota := int64(1) @@ -54,7 +279,7 @@ func testDashboardQuota(t *testing.T, featureToggles []string) { DisableAnonymous: true, EnableQuota: true, DashboardOrgQuota: &dashboardQuota, - EnableFeatureToggles: featureToggles, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -110,27 +335,10 @@ func testDashboardQuota(t *testing.T, featureToggles []string) { } func TestIntegrationUpdatingProvisionionedDashboards(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - testUpdatingProvisionionedDashboards(t, []string{}) -} - -func TestIntegrationUpdatingProvisionionedDashboardsK8s(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - // will be the default in g12 - testUpdatingProvisionionedDashboards(t, []string{featuremgmt.FlagKubernetesClientDashboardsFolders}) -} - -func testUpdatingProvisionionedDashboards(t *testing.T, featureToggles []string) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, - EnableFeatureToggles: featureToggles, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) provDashboardsDir := filepath.Join(dir, "conf", "provisioning", "dashboards") @@ -187,7 +395,7 @@ providers: var dashboardID int64 for _, d := range *dashboardList { dashboardUID = d.UID - dashboardID = d.ID + dashboardID = d.ID // nolint:staticcheck } assert.Equal(t, int64(1), dashboardID) @@ -281,34 +489,10 @@ providers: } func TestIntegrationCreate(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - testCreate(t, []string{}) -} - -func TestIntegrationCreateK8s(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - testCreate(t, []string{featuremgmt.FlagKubernetesClientDashboardsFolders}) -} - -func TestIntegrationPreserveSchemaVersion(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - testPreserveSchemaVersion(t, []string{featuremgmt.FlagKubernetesClientDashboardsFolders}) -} - -func testCreate(t *testing.T, featureToggles []string) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, - EnableFeatureToggles: featureToggles, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -461,10 +645,10 @@ func intPtr(n int) *int { return &n } -func testPreserveSchemaVersion(t *testing.T, featureToggles []string) { +func TestIntegrationPreserveSchemaVersion(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, - EnableFeatureToggles: featureToggles, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -553,25 +737,9 @@ func testPreserveSchemaVersion(t *testing.T, featureToggles []string) { } func TestIntegrationImportDashboardWithLibraryPanels(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - testImportDashboardWithLibraryPanels(t, []string{}) -} - -func TestIntegrationImportDashboardWithLibraryPanelsK8s(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - testImportDashboardWithLibraryPanels(t, []string{featuremgmt.FlagKubernetesClientDashboardsFolders}) -} - -func testImportDashboardWithLibraryPanels(t *testing.T, featureToggles []string) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableAnonymous: true, - EnableFeatureToggles: featureToggles, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, }) grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, path) @@ -762,3 +930,297 @@ func testImportDashboardWithLibraryPanels(t *testing.T, featureToggles []string) }) }) } + +func createDashboard(t *testing.T, grafanaListedAddr string, title string, folderID int64, folderUID string) *dashboards.Dashboard { + t.Helper() + + buf := &bytes.Buffer{} + err := json.NewEncoder(buf).Encode(map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": title, + }, + "folderId": folderID, + "folderUid": folderUID, + "overwrite": true, + }) + require.NoError(t, err) + + u := fmt.Sprintf("http://admin:admin@%s/api/dashboards/db", grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", buf) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var saveResp struct { + Status string `json:"status"` + Slug string `json:"slug"` + Version int64 `json:"version"` + ID int64 `json:"id"` + UID string `json:"uid"` + URL string `json:"url"` + FolderUID string `json:"folderUid"` + } + err = json.Unmarshal(b, &saveResp) + require.NoError(t, err) + require.NotEmpty(t, saveResp.UID) + + return &dashboards.Dashboard{ + ID: saveResp.ID, // nolint:staticcheck + UID: saveResp.UID, + Slug: saveResp.Slug, + Version: int(saveResp.Version), + FolderUID: saveResp.FolderUID, + } +} + +func postDashboard(t *testing.T, grafanaListedAddr, user, password string, payload map[string]interface{}) (*http.Response, error) { + t.Helper() + + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + u := fmt.Sprintf("http://%s:%s@%s/api/dashboards/db", user, password, grafanaListedAddr) + return http.Post(u, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec +} + +func TestIntegrationDashboardServicePermissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + EnableFeatureToggles: []string{featuremgmt.FlagKubernetesClientDashboardsFolders}, + }) + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Login: "editor", + Password: "editor", + IsAdmin: false, + }) + tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + Login: "viewer", + Password: "viewer", + IsAdmin: false, + }) + savedFolder := createFolder(t, grafanaListedAddr, "Saved folder") + otherSavedFolder := createFolder(t, grafanaListedAddr, "Other saved folder") + savedDashInFolder := createDashboard(t, grafanaListedAddr, "Saved dash in folder", savedFolder.ID, savedFolder.UID) // nolint:staticcheck + savedDashInGeneralFolder := createDashboard(t, grafanaListedAddr, "Saved dashboard in general folder", 0, "") + + t.Run("When creating a new dashboard in the General folder, requires create permissions scoped to the general folder", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Dash", + }, + "overwrite": true, + } + + payloadBytes, err := json.Marshal(dashboardPayload) + require.NoError(t, err) + + u := fmt.Sprintf("http://viewer:viewer@%s/api/dashboards/db", grafanaListedAddr) + resp, err := http.Post(u, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + + u = fmt.Sprintf("http://editor:editor@%s/api/dashboards/db", grafanaListedAddr) + resp, err = http.Post(u, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When creating a new dashboard in other folder, requires create permissions scoped to the other folder", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "title": "Dash", + }, + "folderUid": otherSavedFolder.UID, + "overwrite": true, + } + + resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + + resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When creating a new dashboard by existing UID in folder, requires write permissions on the existing dashboard", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder.UID, + "title": "New dash", + }, + "folderUid": savedFolder.UID, + "overwrite": true, + } + + resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + + resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When moving a dashboard by existing uid to other folder from General folder, requires dashboard creation permissions on the destination folder and write access to the dashboard", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInGeneralFolder.UID, + "title": "Dash", + }, + "folderUid": otherSavedFolder.UID, + "overwrite": true, + } + + resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + + resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("When moving a dashboard by existing uid to the General folder from other folder, requires dashboard creation permissions on the general folder and write access to the dashboard", func(t *testing.T) { + dashboardPayload := map[string]interface{}{ + "dashboard": map[string]interface{}{ + "uid": savedDashInFolder.UID, + "title": "Dash", + }, + "folderUid": "", + "overwrite": true, + } + + resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + + resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + }) + + t.Run("RBAC tests", func(t *testing.T) { + setFolderPermissions := func(t *testing.T, grafanaListedAddr string, folderUID string, permissions []map[string]interface{}) { + t.Helper() + + permissionPayload := map[string]interface{}{ + "items": permissions, + } + + payloadBytes, err := json.Marshal(permissionPayload) + require.NoError(t, err) + + u := fmt.Sprintf("http://admin:admin@%s/api/folders/%s/permissions", grafanaListedAddr, folderUID) + resp, err := http.Post(u, "application/json", bytes.NewBuffer(payloadBytes)) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + require.NoError(t, err) + } + + searchDashboards := func(t *testing.T, grafanaListedAddr string, userLogin, userPassword string) []map[string]interface{} { + t.Helper() + + u := fmt.Sprintf("http://%s:%s@%s/api/search?type=dash-db", userLogin, userPassword, grafanaListedAddr) + resp, err := http.Get(u) // nolint:gosec + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + defer resp.Body.Close() // nolint:errcheck + + var results []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&results) + require.NoError(t, err) + + return results + } + + noneUserID := tests.CreateUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleNone), + Login: "noneuser", + Password: "noneuser", + IsAdmin: false, + }) + parentFolder := createFolder(t, grafanaListedAddr, "parent") + childFolder := createFolder(t, grafanaListedAddr, "child") + createDashboard(t, grafanaListedAddr, "dashboard in root", 0, "") + createDashboard(t, grafanaListedAddr, "dashboard in parent", parentFolder.ID, parentFolder.UID) // nolint:staticcheck + createDashboard(t, grafanaListedAddr, "dashboard in child", childFolder.ID, childFolder.UID) // nolint:staticcheck + + viewPermissions := []map[string]interface{}{ + { + "permission": 1, + "userId": noneUserID, + }, + } + t.Run("it should not return folder if ACL is not set for parent folder", func(t *testing.T) { + results := searchDashboards(t, grafanaListedAddr, "noneuser", "noneuser") + assert.Empty(t, results, "Should not return any dashboards when no permissions are set") + }) + + t.Run("it should return child folder when user has permission to read child folder", func(t *testing.T) { + setFolderPermissions(t, grafanaListedAddr, childFolder.UID, viewPermissions) + results := searchDashboards(t, grafanaListedAddr, "noneuser", "noneuser") + + foundTitles := make([]string, 0) + for _, result := range results { + if title, ok := result["title"].(string); ok { + foundTitles = append(foundTitles, title) + } + } + + assert.Contains(t, foundTitles, "dashboard in child", "Should return dashboard in child folder") + }) + + t.Run("it should return parent folder when user has permission to read parent folder but no permission to read child folder", func(t *testing.T) { + setFolderPermissions(t, grafanaListedAddr, parentFolder.UID, viewPermissions) + setFolderPermissions(t, grafanaListedAddr, childFolder.UID, []map[string]interface{}{}) + + results := searchDashboards(t, grafanaListedAddr, "noneuser", "noneuser") + + foundTitles := make([]string, 0) + for _, result := range results { + if title, ok := result["title"].(string); ok { + foundTitles = append(foundTitles, title) + } + } + + assert.Contains(t, foundTitles, "dashboard in parent", "Should return dashboard in parent folder") + assert.NotContains(t, foundTitles, "dashboard in child", "Should not return dashboard in child folder") + }) + }) +} From a2698dc3b5cbbfeeda01115504b1695b3e2807fa Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 29 Jul 2025 09:30:18 +0100 Subject: [PATCH 25/39] Chore: Unskip some a11y story checks and fix any associated issues (#108613) * fix some a11y issues with the stories * fix lockfile * fix tests * put aria-label on * add aria-describedby * undo changes to VizLegendTable * use useID for image id --- .betterer.results | 84 ------------ .../grafana-data/src/themes/createColors.ts | 2 +- .../AutoSaveField/AutoSaveField.story.tsx | 18 ++- .../src/components/Button/Button.story.tsx | 10 +- .../components/Carousel/Carousel.story.tsx | 2 - .../src/components/Carousel/Carousel.test.tsx | 9 +- .../src/components/Carousel/Carousel.tsx | 30 +++-- .../components/Cascader/Cascader.story.tsx | 22 ++- .../ColorPicker/ColorPickerInput.story.tsx | 23 ++-- .../ConfirmButton/ConfirmButton.story.tsx | 2 - .../components/ConfirmButton/DeleteButton.tsx | 7 +- .../ContextMenu/ContextMenu.story.tsx | 5 +- .../DateTimePickers/TimeOfDayPicker.story.tsx | 23 ++-- .../DateTimePickers/TimeOfDayPicker.tsx | 3 + .../src/components/Forms/Field.story.tsx | 24 ++-- .../src/components/Forms/FieldSet.story.tsx | 15 ++- .../src/components/Forms/Form.story.tsx | 126 ++++++++++-------- .../components/Forms/InlineField.story.tsx | 28 ++-- .../InlineToast/InlineToast.story.tsx | 10 +- .../src/components/Input/Input.story.tsx | 37 ++--- .../src/components/Layout/Grid/Grid.story.tsx | 18 +-- .../LoadingBar/LoadingBar.story.tsx | 2 - .../src/components/LoadingBar/LoadingBar.tsx | 2 +- .../PanelChrome/PanelChrome.story.tsx | 2 +- .../src/components/Segment/Segment.story.tsx | 6 +- .../components/Segment/SegmentAsync.story.tsx | 6 +- .../components/Segment/SegmentInput.story.tsx | 5 +- .../src/components/Segment/styles.ts | 8 +- .../components/Select/SelectPerf.story.tsx | 37 +++-- .../StatsPicker/StatsPicker.story.tsx | 29 ++-- .../src/components/Switch/Switch.story.tsx | 11 +- .../TableInputCSV/TableInputCSV.story.tsx | 4 - .../TableInputCSV/TableInputCSV.tsx | 3 +- .../src/components/Tags/TagList.story.tsx | 2 - .../src/components/Tags/TagList.tsx | 10 +- .../src/components/Text/Text.story.tsx | 2 - .../components/ThemeDemos/ThemeDemo.story.tsx | 2 - .../src/components/ThemeDemos/ThemeDemo.tsx | 39 ++++-- .../ThemeDemos/Typography.story.tsx | 10 +- .../ToolbarButton/ToolbarButton.story.tsx | 12 +- .../src/utils/storybook/StoryExample.tsx | 14 +- public/locales/en-US/grafana.json | 4 + public/sass/_variables.light.generated.scss | 2 +- 43 files changed, 348 insertions(+), 362 deletions(-) diff --git a/.betterer.results b/.betterer.results index 1ab5b19047e..95894921943 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4229,36 +4229,12 @@ exports[`no skipping a11y tests in stories`] = { "packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Button/Button.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Carousel/Carousel.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Cascader/Cascader.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/DateTimePickers/TimeOfDayPicker.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/FileDropzone/FileDropzone.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], @@ -4268,45 +4244,21 @@ exports[`no skipping a11y tests in stories`] = { "packages/grafana-ui/src/components/Forms/Checkbox.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Forms/Field.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Forms/FieldArray.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Forms/FieldSet.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Forms/Form.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Forms/InlineField.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/InlineToast/InlineToast.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Input/Input.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Layout/Grid/Grid.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Layout/Stack/Stack.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], "packages/grafana-ui/src/components/Link/TextLink.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/LoadingBar/LoadingBar.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Menu/Menu.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], @@ -4325,54 +4277,18 @@ exports[`no skipping a11y tests in stories`] = { "packages/grafana-ui/src/components/ScrollContainer/ScrollContainer.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Segment/Segment.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Select/Select.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Select/SelectPerf.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Slider/RangeSlider.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], "packages/grafana-ui/src/components/Slider/Slider.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Switch/Switch.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Table/Table.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/TableInputCSV/TableInputCSV.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Tags/TagList.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/Text/Text.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/ThemeDemos/ThemeDemo.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/ThemeDemos/Typography.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], - "packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts index 9f9afcbfaa9..706f87d280e 100644 --- a/packages/grafana-data/src/themes/createColors.ts +++ b/packages/grafana-data/src/themes/createColors.ts @@ -186,7 +186,7 @@ class LightColors implements ThemeColorsBase> { text = { primary: `rgba(${this.blackBase}, 1)`, secondary: `rgba(${this.blackBase}, 0.75)`, - disabled: `rgba(${this.blackBase}, 0.64)`, + disabled: `rgba(${this.blackBase}, 0.65)`, link: this.primary.text, maxContrast: palette.black, }; diff --git a/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.story.tsx b/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.story.tsx index 24df1318d55..c19df03d2c3 100644 --- a/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.story.tsx +++ b/packages/grafana-ui/src/components/AutoSaveField/AutoSaveField.story.tsx @@ -1,5 +1,5 @@ import { StoryFn, Meta } from '@storybook/react'; -import { useState } from 'react'; +import { useId, useState } from 'react'; import { Combobox } from '../Combobox/Combobox'; import { Checkbox } from '../Forms/Checkbox'; @@ -35,8 +35,6 @@ const meta: Meta = { 'validationMessageHorizontalOverflow', ], }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, argTypes: { saveErrorMessage: { control: 'text' }, @@ -76,10 +74,12 @@ const themeOptions = [ export const Basic: StoryFn = (args) => { const [inputValue, setInputValue] = useState(''); + const id = useId(); return ( {(onChange) => ( { const value = e.currentTarget.value; @@ -105,12 +105,19 @@ export const AllComponents: StoryFn = (args) => { const [checkBoxValue, setCheckBoxValue] = useState(false); const [textAreaValue, setTextAreaValue] = useState(''); const [switchValue, setSwitchValue] = useState(false); + const textId = useId(); + const comboboxId = useId(); + const radioButtonId = useId(); + const checkBoxId = useId(); + const textAreaId = useId(); + const switchId = useId(); return (
{(onChange) => ( { const value = e.currentTarget.value; @@ -123,6 +130,7 @@ export const AllComponents: StoryFn = (args) => { {(onChange) => ( { @@ -139,6 +147,7 @@ export const AllComponents: StoryFn = (args) => { > {(onChange) => ( { @@ -155,6 +164,7 @@ export const AllComponents: StoryFn = (args) => { > {(onChange) => ( { > {(onChange) => (