Compare commits

..

68 Commits

Author SHA1 Message Date
Ryan McKinley dfed7ae90c merge main 2025-12-22 13:48:53 +03:00
Ryan McKinley 4c39335c6d merge main 2025-12-22 12:55:56 +03:00
grafana-pr-automation[bot] 5585595c16 I18n: Download translations from Crowdin (#115604)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-20 00:41:06 +00:00
Sean Griffin 6daa7ff729 Clean up Schema Inspector feature code (#115514)
Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
2025-12-19 16:05:46 -05:00
Paul Marbach 8cfac85b48 Gauge: Add guide dots for rounded bars to help with accuracy, update color logic for more consistent gradients (#115285)
* Gauge: Fit-and-finish tweaks to glows, text position, and sparkline size

* adjust text height and positions a little more

* cohesive no data handling

* more tweaks

* fix migration test

* Fix JSON formatting by adding missing newline

* remove new line

* Gauge: Add guide dots for rounded bars to help with accuracy

* 30% width

* remove spotlight, starting to make gradients a bit more predictable

* fix segmented

* update rotation of gauge color

* update i18n and migration tests

* fix spacing

* more fixture updates

* wip: using clip-path and CSS for drawing the gauge

* wip: overhaul color in gauge

* wip: progress on everything

* refactoring defs into utils

* its all working

* fixme comment

* fix backend migration tests

* remove any other mentions of spotlights

* one more tweak

* update gdev

* add lots of tests and reorganize the code a bit

* fix dev dashboard fixture

* more cleanup, optimization

* fix a couple of bugs

* fix bad import

* disable storybook test due to false positive

* a more sweeping disable of the color-contrast

* update backend tests

* update gradient for fixed color

* test all dark/light theme variants

* set opacity to 0.5 for dots

* move min degrees for start dot render to a const

* change endpoint marks to be configurable

* update gdev and fixtures

* i18n

* shore up testing a bit

* remove period for consistency

* hide glow at small angles

* more testing and cleanup

* addressing PR comments

* Update packages/grafana-ui/src/components/RadialGauge/colors.ts

Co-authored-by: Jesse David Peterson <jesdavpet@users.noreply.github.com>

* Update packages/grafana-ui/src/components/RadialGauge/colors.ts

Co-authored-by: Jesse David Peterson <jesdavpet@users.noreply.github.com>

* break out binary search stuff and write tests

* fix lint issues

---------

Co-authored-by: Jesse David Peterson <jesdavpet@users.noreply.github.com>
2025-12-19 20:41:57 +00:00
Renato Costa 0284d1e669 unified-storage: add UnixTimestamp support to the sqlkv implementation (#115651)
* unified-storage: add `UnixTimestamp` support to sqlkv implementation

* unified-storage: improve tests and enable all of them on sqlkv
2025-12-19 15:35:22 -05:00
Adela Almasan 3522efdf32 VizSuggestions: Error handling (#115428)
* error handling

* retry fetching suggestions

* add translation

* useAsyncRetry

* hasError test

* update error handling

* clean up the text panel stuff for the current version

* cleanup for loop

* some more tests for some failure cases

* fix lint issue

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
2025-12-19 20:22:26 +00:00
Stephanie Hingtgen 2fbe2f77e3 Folders: Add max depth check with descendant to /apis (#115305) 2025-12-19 20:17:39 +00:00
Will Assis 4164239f56 unified-storage: implement sqlkv Save method (#115458)
* unified-storage: sqlkv save method
2025-12-19 14:27:06 -05:00
Liza Detrick 14c595f206 Logs: Cell format value on inspect should use Code view for arrays, objects, and JSON strings (#115037) 2025-12-19 19:52:02 +01:00
Isabel Matwawana 471d6f5236 Docs: Add suggested dashboards (#114729) 2025-12-19 13:27:39 -05:00
Jesse David Peterson f91efcfe2c TimeSeries: Fix truncated label text in legend table mode (#115647)
* fix(legend-table): remove arbitrary 600px max width for full width cells

* test(legend-table): backfill test coverage for viz legend table

* test(legend-table): backfill test coverage for viz legend table item

* refactor(legend-table): use derived theme spacing, not hard-coded values
2025-12-19 14:12:01 -04:00
Adela Almasan 49032ae3d7 VizSuggestions: Update selected suggestion styling (#115581)
* update selected suggestion style

* update highlight styles for light theme, add inert to div

* remove commented-out original idea

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
2025-12-19 12:23:27 -05:00
Rodrigo Vasconcelos de Barros 8b316cca25 Alerting: Add tests for AlertRuleMenu component (#115473)
* Alerting: Add tests for AlertRuleMenu component

* Refactor test mocks according TESTING.md

* Remove duplicate mock functions

* Replace snapshot test with more readable assertion

* Remove SETUP_ALERTING_DEV.md file

* Refactor feature flags usage in tests
2025-12-19 11:39:48 -05:00
Collin Fingar fa73caf6c8 Snapshots: Fix V2 Snapshot data coupling (#115278)
* Snapshots: Potential fix for rendering V2 snaps

* removing comments

* Added unit test
2025-12-19 11:30:24 -05:00
Alex Khomenko 0e4b1c7b1e Provisioning: Fix error loop in synchronise step (#115570)
* Refactor requiresMigration

* Remove InlineSecureValueWarning

* Prevent error loop

* Fix error loop

* Cleanup

* i18n
2025-12-19 17:57:45 +02:00
Kristina Demeshchik aa69d97f1e Variables: Show variable reference instead of interpolated datasource in query variable editor (#115624)
show variable ref
2025-12-19 10:38:22 -05:00
Alex Khomenko 7812f783bb Provisioning: Enable editing dashboard via JSON model (#115420)
* Provisioning: Enable save for json model changes

* Do not pass props

* Simplify logic and fix warnings

* add tests

* Show diff for json changes

* Add try/catch
2025-12-19 17:36:46 +02:00
Galen Kistler b2d6bb7a05 logsdrilldowndefaultcolumns: require plugins:write for non GET operations (#115639)
chore: require plugins:write
2025-12-19 15:26:36 +00:00
Anna Urbiztondo 56dd1ca867 Docs: Note for File Provisioning (#115630)
Note
2025-12-19 14:40:33 +00:00
Deyan Halachliyski 62b2a202de Alerting: Add saved searches feature for alert rules page (#115001)
* Alerting: Add saved searches feature for alert rules page

Add ability to save, rename, delete, and apply search queries on the
Alert Rules page. Includes auto-apply default search on navigation
and UserStorage persistence.

Behind feature toggle `alertingSavedSearches` (disabled by default).

* Alerting: Add i18n translations for saved searches

* Alerting: Remove unused imports in saved searches

* Alerting: Add CODEOWNERS for e2e-playwright/alerting-suite

* Alerting: Add useSavedSearches mock to RulesFilter.v2 tests

* Alerting: Fix failing unit tests for saved searches

- Fix Jest mock hoisting issue in useSavedSearches.test.ts by configuring
  UserStorage mock implementation after imports instead of inline
- Update SavedSearches.test.tsx to use findBy* queries for async popup content
- Fix tests to click apply button instead of text for applying searches
- Update maxLength test to verify attribute instead of trying to exceed it

* Alerting: Fix saved searches test mocking and assertions

- Fix UserStorage mock in useSavedSearches.test.ts by creating mock with
  default Promise-returning functions inside jest.mock() factory, then
  accessing the instance via getMockUserStorageInstance() helper
- Fix SavedSearches.test.tsx apply button tests to use correct accessible
  name "Apply this search" (from tooltip) instead of dynamic aria-label
- Fix disabled button assertion to check native disabled attribute instead
  of relying on aria-disabled which is set inconsistently by Button component
- Use findAllByRole for async popup content queries

* Alerting: Fix test query for disabled save button

Use findByText + closest instead of findByRole to find the disabled
"Save current search" button. The Grafana Button component renders
with conflicting accessibility attributes (disabled="" + aria-disabled="false")
which breaks role-based queries in React Testing Library.

* fix(alerting): preserve UserStorage mock reference before clearAllMocks

* fix(alerting): add missing test mocks for crypto and console

- Mock crypto.randomUUID for Node.js test environment
- Add console.error spy to tests expecting storage/parse errors
- Add console.warn spy to test expecting validation warnings

Fixes jest-fail-on-console failures and crypto.randomUUID TypeError.

* fix(alerting): add console.error spy to save failure test

* fix(alerting): address PR review feedback for saved searches

- Register alertingSavedSearches feature toggle in backend
- Extract shared types to SavedSearches.types.ts to fix circular dependencies
- Extract sub-components: InlineSaveInput, InlineRenameInput, SavedSearchItem
- Remove unused imports (IconButton, Input) and styles from SavedSearches.tsx
- Add try/catch for auto-apply default search error handling
- Remove maxLength validation and corresponding test

* fix(alerting): fix validation error display in saved searches

- Fix useEffect dependency array that was immediately clearing validation errors
- Remove error from deps so errors only clear when user types, not when set
- Run i18n-extract to remove unused error-name-too-long translation key

* fix(alerting): address PR review feedback for saved searches

- Replace toHaveBeenCalled assertions with UI verification using AppNotificationList
- Rename useSavedSearches.test.ts to .tsx for JSX support
- Update README documentation to reflect current test patterns
- Add test cleanup between E2E tests to prevent data leakage

* fix(alerting): remove unused import and fix test wrapper

- Remove unused locationService import from RulesFilter.v2.tsx
- Add missing bootData spread in useSavedSearches.test.tsx mock
- Add createWrapper to renderHook call for user-specific storage key test

* fix(alerting): add Redux wrapper to all useSavedSearches hook tests

All renderHook calls for useSavedSearches now include the createWrapper()
which provides the Redux Provider context required by useAppNotification.

* fix(alerting): use regex patterns in MSW handlers for UserStorage tests

MSW handlers now use regex patterns to match any namespace and user UID,
since UserStorage reads config values from internal imports that aren't
affected by jest.mock of @grafana/runtime.

* fix(alerting): mock UserStorage directly instead of using MSW

Replace MSW HTTP handlers with a direct mock of the UserStorage class.
The MSW approach failed because UserStorage evaluates config.namespace
at module load time, before jest.mock takes effect, causing the regex
patterns to not match the actual request URLs.

This follows the same pattern used in useFavoriteDatasources.test.ts.

* refactor(alerting): use react-hook-form and Dropdown for saved searches

- Migrate InlineRenameInput and InlineSaveInput to react-hook-form
- Replace custom PopupCard with Grafana Dropdown component
- Use useReducer for centralized dropdown state management
- Add stopPropagation handlers to prevent dropdown closing during form interactions
- Update tests to use real useSavedSearches hook with mocked UserStorage
- Consolidate and simplify saved searches test suite

* fix: resolve CI failures in SavedSearches component

- Fix TypeScript TS2540 errors by using MutableRefObject type for refs
- Fix form submission by using onClick instead of type="submit" on IconButton
  (IconButton doesn't forward the type prop to the underlying button)
- Fix action menu tests by stopping click propagation on ActionMenu wrapper
- Fix Escape key handling by focusing the dialog element instead of the
  potentially-disabled save button

* fix(alerting): add navTree to runtime mock in useSavedSearches tests

Add empty navTree array to the @grafana/runtime config mock to prevent
store initialization crash when buildInitialState() calls .find() on
undefined navTree.

* fix(alerting): add error handling for auto-apply default search

Wrap handleApplySearch call in try-catch to prevent unhandled exceptions
when auto-applying the default saved search on navigation.

* fix(alerting): prevent saved searches dropdown from closing when clicking action menu

The nested Dropdown components caused the outer SavedSearches dropdown to close
when clicking on action menu items (Set as default, Rename, Delete). This happened
because @floating-ui/react's useDismiss hook detected clicks on the inner Menu
(rendered via Portal) as "outside" clicks.

Fix: Replace the outer Dropdown with PopupCard and add custom click-outside
handling that explicitly excludes portal elements ([role="menu"] and
[data-popper-placement]). This matches the pattern used before the Dropdown
refactor.

Changes:
- SavedSearches.tsx: Use PopupCard instead of Dropdown, add click-outside handler
- SavedSearchItem.tsx: Add menuPortalRoot prop for action menu positioning
- RulesFilter.v2.tsx: Fix double analytics tracking on auto-apply

* fix(alerting): auto-apply default saved search on page navigation

The default saved search was not being applied when navigating to the
Alert rules page. This was caused by a race condition where `isLoading`
was `false` on initial render (status was 'not-executed'), causing the
auto-apply effect to run before saved searches were loaded.

Fix: Include the uninitialized state in the loading check so the effect
waits until data is actually loaded before attempting to auto-apply.

Also adds tests for the auto-apply functionality.

* fix(alerting): align action menu icon and improve saved search tests

- Fix vertical alignment of three-dot menu icon in saved search items
  by adding flex centering to the wrapper div
- Add feature toggle setup/teardown in saved searches test suite
- Fix location mocking in test for URL search parameter handling

* refactor(alerting): improve saved searches validation and organization

- Rename SavedSearches.types.ts to savedSearchesSchema.ts
- Use react-hook-form's built-in validation instead of manual setError
- Change error handling to throw ValidationError instead of returning it
- Add type guard isValidationError for safe error checking
- Add alphabetical sorting for saved searches (default first)
- Replace console.warn/error with logWarning/logError for analytics
- Extract helper functions: sortSavedSearches, loadSavedSearchesFromStorage, hasUrlSearchQuery

* refactor(alerting): address PR review comments for saved searches (steps 9-12)

- Add comprehensive comment explaining useEffect double-render limitation
  and potential future improvements for default search auto-apply (step 9)
- Add test documenting expected behavior when navigating back to alert list
  after leaving the page - default filter is re-applied (step 10)
- Update RulesFilter.v2.test.tsx to use testWithFeatureToggles helper and
  add MSW UserStorage handlers for future use (step 11)
- Update SavedSearches.test.tsx to use render from test/test-utils and
  byRole selectors for menu items (step 12)

* test(alerting): update saved searches tests for refactored API

- Update mockSavedSearches order to match sorted output (default first, then alphabetically)
- Change validation error tests to use rejects pattern (saveSearch/renameSearch now throw)
- Add hasPermission mock to contextSrv for module-level permission check

* fix(alerting): fix CI failures for saved searches

- Update onRenameComplete type to match throw-based API (Promise<void>)
- Run i18n-extract to add missing translation keys

* fix(alerting): salvage valid entries when saved searches validation fails

Instead of returning an empty array when array validation fails,
iterate through each item and keep only the valid entries.
This prevents losing all saved searches if a single entry is corrupted.

* test(alerting): update test to expect valid entries to be preserved

Update the test assertion to match the new behavior where valid saved
search entries are preserved when some entries fail validation, rather
than discarding all entries.

* fix(alerting): eliminate double API request on saved search auto-apply

Move saved searches loading and auto-apply logic from RulesFilterV2 to
RuleListPage. This ensures the default search filter is applied BEFORE
FilterView mounts, preventing double API requests on initial page load.

- Load saved searches at RuleListPage level
- Gate RuleList rendering until saved searches are loaded
- Pass savedSearchesResult as prop to avoid duplicate hook calls
- Remove auto-apply tests from RulesFilter.v2.test.tsx (behavior moved)

* fix(alerting): mock useSavedSearches in RuleList.v2 tests

The useSavedSearches hook triggers async state updates that complete
after tests finish, causing React act() warnings. Mock the hook to
prevent async operations during tests.

* refactor(alerting): migrate saved searches tests to use MSW

Address code review feedback by migrating UserStorage tests from
jest.mock to MSW-based mocking:

- Add MSW helper functions (setAlertingStorageItem, getAlertingStorageItem)
  to simplify test setup for UserStorage
- Migrate useSavedSearches.test.tsx to use MSW handlers instead of
  jest.mock('@grafana/runtime/internal')
- Migrate RulesFilter.v2.test.tsx to use MSW handlers
- Update README documentation to accurately reflect how tests use MSW
- Add tests for default search auto-apply behavior in RuleListPage
- Simplify comments to be concise and accurate

* fix(alerting): mock UserStorage directly in useSavedSearches tests

The UserStorage class caches its storage spec at the instance level,
and the useSavedSearches hook creates the instance at module level.
This caused test isolation issues where cached state leaked between
tests, making all tests that depended on loading data fail.

Fix by mocking UserStorage class directly instead of relying on MSW
handlers. This gives each test explicit control over what getItem
and setItem return, ensuring proper isolation.

Also update persistence assertions to verify mock.setItem calls
instead of reading from MSW storage (which the mock bypasses).

* refactor(alerting): remove setup helper in SavedSearches tests

Replace the `setup()` helper function with direct `render()` calls
as suggested in PR review. This makes tests more explicit about
what component is being rendered and with what props.

* refactor(alerting): extract default search auto-apply into dedicated hook

Moves the default saved search auto-apply logic from useSavedSearches into
a new useApplyDefaultSearch hook. This improves separation of concerns by
keeping useSavedSearches focused on CRUD operations while the new hook
handles the page-level auto-apply behavior.

Key changes:
- Created useApplyDefaultSearch hook with session-based visit tracking
- Removed getAutoApplySearch method and user-specific session keys from useSavedSearches
- Exported loadDefaultSavedSearch utility for independent default search loading
- Simplified test mocks to use loadDefaultSavedSearch instead of full hook mocking
- Removed unused savedSearchesResult prop passing through component tree

* fix(alerting): improve default search auto-apply timing and test reliability

Replace react-use's auto-executing useAsync with internal useAsync hook
for better control over when default search is loaded. This prevents
race conditions and ensures the async operation only executes when needed.

Test improvements:
- Add proper session storage cleanup in beforeEach
- Use waitFor to handle async operations correctly
- Prevent visited flag from affecting subsequent tests
- Clear mock call history between tests

The internal useAsync hook doesn't auto-execute on mount, allowing us to
control exactly when the default search loads based on conditions rather
than relying on dependency array triggers.

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
2025-12-19 15:32:27 +01:00
Matheus Macabu 133865182e CI: Add e2e-playwright folder to e2e test detection changes (#115623) 2025-12-19 15:21:22 +01:00
Renato Costa 338ae95ef5 unified-storage: add BatchDelete support to sqlkv implementation (#115573) 2025-12-19 09:15:23 -05:00
Anna Urbiztondo 19c9f21cc4 Docs: Corrections for full instance sync (#115615)
* Corrections for full instance sync

* Edits

* Feedback

* Migration checkbox

* Edit

* Update docs/sources/as-code/observability-as-code/provision-resources/git-sync-setup.md

Co-authored-by: Roberto Jiménez Sánchez <roberto.jimenez@grafana.com>

* Mention to export

* Prettier

---------

Co-authored-by: Roberto Jiménez Sánchez <roberto.jimenez@grafana.com>
2025-12-19 15:13:35 +01:00
Roberto Jiménez Sánchez 9760eef62f Provisioning: fix multi-tenant and single-tenant authorization (#115435)
* feat(auth): add ExtraAudience option to RoundTripper

Add ExtraAudience option to RoundTripper to allow operators to include
additional audiences (e.g., provisioning group) when connecting to the
multitenant aggregator. This ensures tokens include both the target API
server's audience and the provisioning group audience, which is required
to pass the enforceManagerProperties check.

- Add ExtraAudience RoundTripperOption
- Improve documentation and comments
- Add comprehensive test coverage

* fix(operators): add ExtraAudience for dashboards/folders API servers

Operators connecting to dashboards and folders API servers need to include
the provisioning group audience in addition to the target API server's
audience to pass the enforceManagerProperties check.

* provisioning: fix settings/stats authorization for AccessPolicy identities

The settings and stats endpoints were returning 403 for users accessing via
ST->MT because the AccessPolicy identity was routed to the access checker,
which doesn't know about these resources.

This fix handles 'settings' and 'stats' resources before the access checker
path, routing them to the role-based authorization that allows:
- settings: Viewer role (read-only, needed by frontend)
- stats: Admin role (can leak information)

* fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic

- Removed legacy storage flag from useResourceStats hook in BootstrapStep.
- Updated BootstrapStepResourceCounting to simplify rendering logic and removed target prop.
- Adjusted tests to reflect changes in resource counting and rendering behavior.

* Revert "fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic"

This reverts commit 148802cbb5.

* provisioning: allow any authenticated user for settings/stats endpoints

These are read-only endpoints needed by the frontend:
- settings: returns available repository types and configuration for the wizard
- stats: returns resource counts

Authentication is verified before reaching authorization, so any user who
reaches these endpoints is already authenticated. Requiring specific org
roles failed for AccessPolicy tokens which don't carry traditional roles.

* provisioning: remove redundant admin role check from listFolderFiles

The admin role check in listFolderFiles was redundant (route-level auth already
handles access) and broken for AccessPolicy identities which don't have org roles.

File access is controlled by the AccessClient as documented in the route-level
authorization comment.

* provisioning: add isAdminOrAccessPolicy helper for auth checks

Consolidates authorization logic for provisioning endpoints:
- Adds isAdminOrAccessPolicy() helper that allows admin users OR AccessPolicy identities
- AccessPolicy identities (ST->MT flow) are trusted internal callers without org roles
- Regular users must have admin role (matching frontend navtree restriction)

Used in: authorizeSettings, authorizeStats, authorizeJobs, listFolderFiles

* provisioning: consolidate auth helpers into allowForAdminsOrAccessPolicy

Simplifies authorization by:
- Adding isAccessPolicy() helper for AccessPolicy identity check
- Adding allowForAdminsOrAccessPolicy() that returns Decision directly
- Consolidating stats/settings/jobs into single switch case
- Using consistent pattern in files.go

* provisioning: require admin for files subresource at route level

Aligns route-level authorization with handler-level check in listFolderFiles.
Both now require admin role OR AccessPolicy identity for consistency.

* provisioning: restructure authorization with role-based helpers

Reorganizes authorization code for clarity:

Role-based helpers (all support AccessPolicy for ST->MT flow):
- allowForAdminsOrAccessPolicy: admin role required
- allowForEditorsOrAccessPolicy: editor role required
- allowForViewersOrAccessPolicy: viewer role required

Repository subresources by role:
- Admin: repository CRUD, test, files
- Editor: jobs, resources, sync, history
- Viewer: refs, status (GET only)

Connection subresources by role:
- Admin: connection CRUD
- Viewer: status (GET only)

* provisioning: move refs to admin-only

refs subresource now requires admin role (or AccessPolicy).
Updated documentation comments to reflect current permissions.

* provisioning: add fine-grained permissions for connections

Adds connection permissions following the same pattern as repositories:
- provisioning.connections:create
- provisioning.connections:read
- provisioning.connections:write
- provisioning.connections:delete

Roles:
- fixed:provisioning.connections:reader (granted to Admin)
- fixed:provisioning.connections:writer (granted to Admin)

* provisioning: remove non-existent sync subresource from auth

The sync subresource doesn't exist - syncing is done via the jobs endpoint.
Removed dead code from authorization switch case.

* provisioning: use access checker for fine-grained permissions

Refactors authorization to use b.access.Check() with verb-based checks:

Repository subresources:
- CRUD: uses actual verb (get/create/update/delete)
- test: uses 'update' (write permission)
- files/refs/resources/history/status: uses 'get' (read permission)
- jobs: uses actual verb for jobs resource

Connection subresources:
- CRUD: uses actual verb
- status: uses 'get' (read permission)

The access checker maps verbs to actions defined in accesscontrol.go.
Falls back to admin role for backwards compatibility.

Also removes redundant admin check from listFolderFiles since
authorization is now properly handled at route level.

* provisioning: use verb constants instead of string literals

Uses apiutils.VerbGet, apiutils.VerbUpdate instead of "get", "update".

* provisioning: use access checker for jobs and historicjobs resources

Jobs resource: uses actual verb (create/read/write/delete)
HistoricJobs resource: read-only (historicjobs:read)

* provisioning: allow viewers to access settings endpoint

Settings is read-only and needed by multiple UI pages (not just admin pages).
Stats remains admin-only.

* provisioning: consolidate role-based resource authorization

Extract isRoleBasedResource() and authorizeRoleBasedResource() helpers
to avoid duplicating settings/stats resource checks in multiple places.

* provisioning: use resource name constants instead of hardcoded strings

Replace 'repositories', 'connections', 'jobs', 'historicjobs' with
their corresponding ResourceInfo.GetName() constants.

* provisioning: delegate file authorization to connector

Route level: allow any authenticated user for files subresource
Connector: check repositories:read only for directory listing
Individual file CRUD: handled by DualReadWriter based on actual resource

* provisioning: enhance authorization for files and jobs resources

Updated file authorization to fall back to admin role for listing files. Introduced checkAccessForJobs function to manage job permissions, allowing editors to create and manage jobs while maintaining admin-only access for historic jobs. Improved error messaging for permission denials.

* provisioning: refactor authorization with fine-grained permissions

Authorization changes:
- Use access checker with role-based fallback for backwards compatibility
- Repositories/Connections: admin role fallback
- Jobs: editor role fallback (editors can manage jobs)
- HistoricJobs: admin role fallback (read-only)
- Settings: viewer role (needed by multiple UI pages)
- Stats: admin role

Files subresource:
- Route level allows any authenticated user
- Directory listing checks repositories:read in connector
- Individual file CRUD delegated to DualReadWriter

Refactored checkAccessWithFallback to accept fallback role parameter.

* provisioning: refactor access checker integration for improved authorization

Updated the authorization logic to utilize the new access checker across various resources, including files and jobs. This change simplifies the permission checks by removing redundant identity retrieval and enhances error handling. The access checker now supports role-based fallbacks for admin and editor roles, ensuring backward compatibility while streamlining the authorization process for repository and connection subresources.

* provisioning: remove legacy access checker tests and refactor access checker implementation

Deleted the access_checker_test.go file to streamline the codebase and focus on the updated access checker implementation. Refactored the access checker to enhance clarity and maintainability, ensuring it supports role-based fallback behavior. Updated the access checker integration in the API builder to utilize the new fallback role configuration, improving authorization logic across resources.

* refactor: split AccessChecker into TokenAccessChecker and SessionAccessChecker

- Renamed NewMultiTenantAccessChecker -> NewTokenAccessChecker (uses AuthInfoFrom)
- Renamed NewSingleTenantAccessChecker -> NewSessionAccessChecker (uses GetRequester)
- Split into separate files with their own tests
- Added mockery-generated mock for AccessChecker interface
- Names now reflect identity source rather than deployment mode

* fix: correct error message case and use accessWithAdmin for filesConnector

- Fixed error message to use lowercase 'admin role is required'
- Fixed filesConnector to use accessWithAdmin for proper role fallback
- Formatted code

* refactor: reduce cyclomatic complexity in filesConnector.Connect

Split the Connect handler into smaller focused functions:
- handleRequest: main request processing
- createDualReadWriter: setup dependencies
- parseRequestOptions: extract request options
- handleDirectoryListing: GET directory requests
- handleMethodRequest: route to method handlers
- handleGet/handlePost/handlePut/handleDelete: method-specific logic
- handleMove: move operation logic

* security: remove blind TypeAccessPolicy bypass from access checkers

Removed the code that bypassed authorization for TypeAccessPolicy identities.
All identities now go through proper permission verification via the inner
access checker, which will validate permissions from ServiceIdentityClaims.

This addresses the security concern where TypeAccessPolicy was being trusted
blindly without verifying whether the identity came from the wire or in-process.

* feat: allow editors to access repository refs subresource

Change refs authorization from admin to editor fallback so editors can
view repository branches when pushing changes to dashboards/folders.

- Split refs from other read-only subresources (resources, history, status)
- refs now uses accessWithEditor instead of accessWithAdmin
- Updated documentation comment to reflect authorization levels
- Added integration test TestIntegrationProvisioning_RefsPermissions
  verifying editor access and viewer denial

* tests: add authorization tests for missing provisioning API endpoints

Add comprehensive authorization tests for:
- Repository subresources (test, resources, history, status)
- Connection status subresource
- HistoricJobs resource
- Settings and Stats resources

All authorization paths are now covered by integration tests.

* test: fix RefsPermissions test to use GitHub repository

Use github-readonly.json.tmpl template instead of local folder,
since refs endpoint requires a versioned repository that supports
git operations.

* chore: format test files

* fix: make settings/stats authorization work in MT mode

Update authorizeRoleBasedResource to check authlib.AuthInfoFrom(ctx)
for AccessPolicy identity type in addition to identity.GetRequester(ctx).
This ensures AccessPolicy identities are recognized in MT mode where
identity.GetRequester may not set the identity type correctly.

* fix: remove unused authorization helper functions

Remove allowForAdminsOrAccessPolicy and allowForViewersOrAccessPolicy
as they are no longer used after refactoring to use authorizeRoleBasedResource.

* Fix AccessPolicy identity detection in ST authorizer

- Add check for AccessPolicy identities via GetAuthID() in authorizeRoleBasedResource
- Extended JWT may set identity type to TypeUser but AuthID is 'access-policy:...'
- Forward user ID token in X-Grafana-Id header in RoundTripper for aggregator forwarding

* Revert "Fix AccessPolicy identity detection in ST authorizer"

This reverts commit 0f4885e503.

* Add fine-grained permissions for settings and stats endpoints

- Add provisioning.settings:read action (granted to Viewer role)
- Add provisioning.stats:read action (granted to Admin role)
- Add accessWithViewer to APIBuilder for Viewer role fallback
- Use access checker for settings/stats authorization
- Remove role-based authorization functions (isRoleBasedResource, authorizeRoleBasedResource)

This makes settings and stats consistent with other provisioning resources
and works properly in both ST and MT modes via the access checker.

* Remove AUTHORIZATION_COVERAGE.md

* Add provisioning resources to RBAC mapper

- Add connections, settings, stats to provisioning.grafana.app mappings
- Required for authz service to translate K8s verbs to legacy actions
- Fixes 403 errors for settings/stats in MT mode

* refactor: merge access checkers with original fallthrough behavior

Merge tokenAccessChecker and sessionAccessChecker into a unified
access checker that implements the original fallthrough behavior:

1. First try to get identity from access token (authlib.AuthInfoFrom)
2. If token exists AND (is TypeAccessPolicy OR useExclusivelyAccessCheckerForAuthz),
   use the access checker with token identity
3. If no token or conditions not met, fall back to session identity
   (identity.GetRequester) with optional role-based fallback

This fixes the issue where settings/stats/connections endpoints were
failing in MT mode because the tokenAccessChecker was returning an error
when there was no auth info in context, instead of falling through to
session-based authorization.

The unified checker now properly handles:
- MT mode: tries token first, falls back to session if no token
- ST mode: only uses token for AccessPolicy identities, otherwise session
- Role fallback: applies when configured and access checker denies

* Revert "refactor: merge access checkers with original fallthrough behavior"

This reverts commit 96451f948b.

* Grant settings view role to all

* fix: use actual request verb for settings/stats authorization

Use a.GetVerb() instead of hardcoded VerbGet for settings and stats
authorization. When listing resources (hitting collection endpoint),
the verb is 'list' not 'get', and this mismatch could cause issues
with the RBAC service.

* debug: add logging to access checkers for authorization debugging

Add klog debug logs (V4 level) to token and session access checkers
to help diagnose why settings/stats authorization is failing while
connections works.

* debug: improve access checker logging with grafana-app-sdk logger

- Use grafana-app-sdk logging.FromContext instead of klog
- Add error wrapping with resource.group format for better context
- Log more details including folder, group, and allowed status
- Log error.Error() for better error message visibility

* chore: use generic log messages in access checkers

* Revert "Grant settings view role to all"

This reverts commit 3f5758cf36.

* fix: use request verb for historicjobs authorization

The original role-based check allowed any verb for admins. To preserve
this behavior with the access checker, we should pass the actual verb
from the request instead of hardcoding VerbGet.

---------

Co-authored-by: Charandas Batra <charandas.batra@grafana.com>
2025-12-19 15:11:35 +01:00
Marcus Andersson ece38641ca Dashboards: Make sure to render dashboard links even if they are marked as "in controls menu" (#115381)
links with type dashboard will now be visible.
2025-12-19 13:48:53 +01:00
Yulia Shanyrova e9a2828f66 Plugins: Add PluginInsights UI (#115616)
* Add getInsights endpoint, add new component PluginInsights

* fix linting and add styles

* add version option to insights request

* Add plugininsights tests, remove console.logs

* fix the insight items types

* Add getting insights to all the mocks to fix the tests

* remove deprecated lint package

* Add theme colors, added tests to PluginDetailsPanel

* Fix eslint error for plugin details page

* Add pluginInsights feature toggle

* change getInsights with version API call, resolve conflicts with main

* fix typecheck and translation

* updated UI

* update registry go

* fix translation

* light css changes

* remove duplicated feature toggle

* fix the build

* update plugin insights tests

* fix typecheck

* rudderstack added, feedback form added

* fix translation

* Remove isPluginTabId function
2025-12-19 13:40:41 +01:00
Sonia Aguilar c2275f6ee4 Alerting: Add Cursor frontmatter to CLAUDE.md for auto-loading (#115613)
add Cursor frontmatter to CLAUDE.md for auto-loading
2025-12-19 12:03:45 +00:00
Yulia Shanyrova b4eb02a6f0 Plugins: Change pageId parameter type in usePluginDetailsTabs (#115612)
* change usePluginDetailsTabs pageId parameter type

* add eslint suppressions
2025-12-19 12:45:15 +01:00
Roberto Jiménez Sánchez a0751b6e71 Provisioning: Default to folder sync only and block new instance sync repositories (#115569)
* Default to folder sync only and block new instance sync repositories

- Change default allowed_targets to folder-only in backend configuration
- Modify validation to only enforce allowedTargets on CREATE operations
- Add deprecation warning for existing instance sync repositories
- Update frontend defaults and tests to reflect new behavior

Fixes #619

* Update warning message: change 'deprecated' to 'not fully supported'

* Fix health check: don't validate allowedTargets for existing repositories

Health checks for existing repositories should treat them as UPDATE operations,
not CREATE operations, so they don't fail validation for instance sync target.

* Fix tests and update i18n translations

- Update BootstrapStep tests to reflect folder-only default behavior
- Run i18n-extract to update translation file structure

* Fix integration tests

* Fix tests

* Fix provisioning test wizard

* Fix fronted test
2025-12-19 11:44:15 +00:00
Alexander Akhmetov b5793a5f73 Alerting: Fix receiver_name and has_prometheus_definition filters with compact=true (#115582) 2025-12-19 11:43:46 +01:00
Misi 285f2b1d32 Auth: Allow service accounts to authenticate to ST Grafana (#115536)
* Allow SAs to authn ext_jwt

* Address feedback
2025-12-19 09:28:20 +00:00
Tania 7360194ab9 Chore: Remove unifiedReqeustLog feature flag (#115559)
Chore: Remove unifiedReqeustLog feature flag
2025-12-19 09:55:47 +01:00
Ryan McKinley 4a20665cf7 lint fix 2025-12-19 07:57:23 +03:00
Ryan McKinley 50bf29b050 should not have a real change 2025-12-19 07:55:41 +03:00
Ryan McKinley a311bd0e84 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-19 07:54:52 +03:00
Will Assis 99f5f14de7 unified-storage: move rvmanager into its own package (#115445)
* unified-storage: move rvmanager into its own package so it can be reused with sqlkv later
2025-12-18 18:35:32 -05:00
Collin Fingar 606a59584a Saved Queries: Pass editor ref for dynamic dropdown display (#114321)
* Saved Queries: Pass editor ref for dynamic dropdown display

* Updated docs per feedback

* Update docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/explore/get-started-with-explore.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Update docs/sources/visualizations/panels-visualizations/query-transform-data/_index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

---------

Co-authored-by: Nathan Marrs <nathanielmarrs@gmail.com>
Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
2025-12-18 18:18:24 -05:00
Nathan Marrs 0ec716a433 Embedded Dashboard Panels: Add Grafana Branding (#115198)
* feat: add Grafana logo to embedded panels

- Add Grafana logo watermark to solo panel view (embedded panels)
- Logo appears in top-right corner with subtle background container
- Logo hides on hover to avoid interfering with panel content
- Uses React state to track hover for reliable behavior across nested elements

* minor formatting

* update changes to match public dashboards styling

* match styles of public dashboards

* feat: add responsive Grafana branding to embedded panels

- Add 'Powered by Grafana' branding with text logo to solo panel view
- Implement responsive scaling based on panel dimensions (0.6x to 1.0x)
- Logo and text scale proportionally with panel size
- Branding hides on hover to avoid interfering with panel content
- Matches public dashboard branding pattern for consistency
- Uses ResizeObserver for efficient responsive updates

* feat: add Grafana branding to embedded solo panels

- Add 'Powered by Grafana' branding with text logo to embedded panels
- Create SoloPanelPageLogo component for reusable branding
- Implement responsive scaling based on panel dimensions
- Add hover-to-hide functionality to avoid content overlap
- Logo scales between 0.6x and 1.0x based on panel size

* refactor: move scale calculation into SoloPanelPageLogo component

- Move responsive scale calculation logic from SoloPanelRenderer to SoloPanelPageLogo
- Logo component now manages its own scaling based on container dimensions
- Improves separation of concerns and component encapsulation

* feat: add hideLogo query parameter to disable embedded panel branding

- Add hideLogo query parameter support to SoloPanelPage
- Logo can be hidden via ?hideLogo, ?hideLogo=true, or ?hideLogo=1
- Useful for customers who want to disable branding and for image rendering scenarios
- Update Props interface to include hideLogo in queryParams type

* feat: hide logo in panel image renderer URLs

- Add hideLogo=true parameter to image renderer URLs in ShareLinkTab
- Ensures logo is hidden when generating panel images through share feature
- Update test to expect hideLogo=true in render URL

* feat: hide logo in old dashboard sharing panel image URLs

- Add hideLogo=true parameter to buildImageUrl in ShareModal utils
- Ensures logo is hidden when generating panel images through old share modal
- Update all ShareLink tests to expect hideLogo=true in render URLs

* test: add comprehensive tests for SoloPanelPage and SoloPanelPageLogo

- Add SoloPanelPageLogo tests covering rendering, hover behavior, theme selection, and scaling
- Add SoloPanelPage tests covering logo visibility based on hideLogo prop
- Test logo hiding functionality (most important behavior)
- Test responsive scaling based on container dimensions
- Test ResizeObserver integration
- All 14 tests passing

* refactor: centralize hideLogo handling in SoloPanelPageLogo

Move hideLogo parsing and decision-making into SoloPanelPageLogo so SoloPanelPage/SoloPanelRenderer only pass through the raw query param value.

* chore: clean up solo logo test and share link params

Remove a duplicate SVG mock in SoloPanelPageLogo.test, and simplify ShareLinkTab image URL building without changing behavior.

* chore: revert ShareLinkTab image query refactor

Restore the previous image URL query-param mutation logic in ShareLinkTab to reduce risk.

* chore: set hideLogo once for ShareLinkTab image URLs

Avoid passing hideLogo twice when building the rendered image URL.

* fix: handle boolean hideLogo query param in SoloPanelPageLogo

Handle query params that are represented as booleans (e.g., ?hideLogo) and arrays, and avoid calling trim() on non-strings.

* fix i18n

* fix(dashboard-scene): address SoloPanelPageLogo review feedback

Avoid double-scaling logo margin, clarify scaling comments, and extend tests for null/array values and ResizeObserver cleanup.

* update margin left on logo to better match text spacing
2025-12-18 15:01:16 -08:00
Leon Sorokin 72e1f1e546 Heatmap: Support for linear y axis (#113337)
* wip

* boop

* Base factor on data

* Add some basic option control

* Remove old comments

* Add feature flag

* Apply feature flag to axis options

* Turn factor calculation into exported function

* Simplify bucket factor function

* Clarify comments

* Fix cell sizing of pre-bucketed heatmaps with log

* Remove unnecessary category change

* Consolidate editor for calculate from data no

* Update bucket function sanity checks

* Wire up scale config from yBucketScale

* Hide bucket controls for heatmap cells

* Fix splits

* Add test coverage

* Fix failing test

* Add basic util test coverage

* Fix tooltip for legacy in linear

* Fix y bucket option width to be consistent

* Hide tick alignment for explicit scale modes

* Clarify comment

* Make sure units are passed properly for linear

* Remove null assertion operator

* Clean up nested ternary

* Add type protection to scaleLog

* Remove repeated code for ySize calcs

* Remove ternary for scaleDistribution

* Add test coverage for YBucketScaleEditor

* Add isHeatmapSparse function to tooltip utils

* Create calculateYSizeDivisor util function

* Fix y axis min and max options and extend to log

* Add toLogBase test coverage

* Create applyExplicitMinMax function

* Add additional test coverage for scale editor

* Run i18n-extract

* Update eslint suppressions

---------

Co-authored-by: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com>
2025-12-18 14:45:00 -08:00
Haris Rozajac 37c1e3fb02 Dashboard Schema v1beta1 to v2alpha1: Preserve string template variable datasource references in query variables (#115516)
* Dashboard migration: preserve legacy string datasource references

Fix v1beta1 → v2alpha1 conversion to handle legacy string datasource
references in QueryVariable, AdhocVariable, and GroupByVariable.

Previously, string datasource references (both template variables like
"$datasource" and direct names/UIDs like "prometheus") were being
dropped during conversion, causing variable chaining to break.

The frontend's DatasourceSrv.getInstanceSettings() already handles
string references by trying uid → name → id lookup at runtime, so we
preserve the string in the uid field and let the frontend resolve it.

* trigger frontend ci tests when dashboard migration code changes

* v1: if string convert to DS ref

* Update migration testdata to fix template variable datasource references

* update
2025-12-18 15:11:09 -07:00
Denis Vodopianov 39c562a911 Revert: chore: a drop-in replacement for FeatureToggles.IsEnabledGlobally in app settings (#115593)
* Revert "chore: a drop-in replacement for FeatureToggles.IsEnabledGlobally in app settings (#113449)"

This reverts commit 26ce2c09d7.

* Change FeatureToggles.IsEnabledGlobally deprecation message
2025-12-18 16:46:32 -05:00
Haris Rozajac 05fd304dbd Dashboards: AdHoc and GroupBy wrapper (#115124)
* wip; DrilldownControls

* use wrapper so that drilldown controls wrap inline

* keep labels on top when input expands vertically

* add clear all button

* add collapsible prop

* i18n

* Increase maxWidth for adhoc

* bump scenes for testing

* fix

* remove clear all button

* use new feature toggle; pass collapsible in v2

* update variable controls to use new feature flag

* cleanup

* wip (#115441)

* wip

* fix

* update wrapping on smaller screens

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>

* Filter out variables that are not in inControlsMenu

* filter out inControlsMenu vars, not hidden ones

* canary scenes

* fix

* cleanup

* canary scenes

* pass wideInput to groupby based on ff

* update var name and bump scenes

* bump scenes

* yarn lock

---------

Co-authored-by: Victor Marin <victor.marin@grafana.com>
2025-12-18 11:58:21 -07:00
Laura Fernández 1850163346 Rudderstack: Add new config option for rudderstack v3 url (#115374) 2025-12-18 19:47:04 +01:00
Denis Vodopianov 26ce2c09d7 chore: a drop-in replacement for FeatureToggles.IsEnabledGlobally in app settings (#113449) 2025-12-18 13:10:30 -05:00
Galen Kistler 051cdaad0d Revert "Plugins: Add PluginInsights UI (#111603)" (#115574)
This reverts commit 1f4f2b4d7c.
2025-12-18 17:11:33 +00:00
Mihai Doarna 1862e5dac5 IAM: Fix team search for unistore (#115250)
* fix team search for unistore

* fix search in unistore

* remove field prefix when generating the response

* fix unit test

* address feedback
2025-12-18 18:54:55 +02:00
Renato Costa 19f6dbe1bb unified-storage: add BatchGet support to the sqlkv implementation (#115517)
* unified-storage: add `BatchGet` support to the sqlkv implementation

* address comments

* fix linting
2025-12-18 11:21:36 -05:00
Igor Suleymanov facb25a09c Fix Grafana App SDK logger log level (#115551)
* Fix Grafana App SDK logger log level

What

This commit fixes the hardcoded value of the app SDK logger log level
by properly setting it during the log manager initialization.

Why

To prevent app SDK logging from always logging at DEBUG.

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

* Add missing argument to the logging test

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

---------

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>
2025-12-18 18:07:48 +02:00
Charandas d0792ebe97 Secrets: Add gRPC client retry with exp. backoff" (#115526)
Provisioning: secrets decrypt client should retry with exponential backoff
2025-12-18 07:44:33 -08:00
Alexa Vargas 98aa6c50dc DashboardLibrary: Force v1 dashboard scene page manager when loading template dashboards (#115488)
Force v1 manager for template dashboards feature
2025-12-18 16:42:37 +01:00
Vardan Torosyan a65aa9d18f SCIM Docs: Replace warning with an information text for SAML identifier (#115353)
* SCIM Docs: Replace warning with an information text for SAML identifier

* Fix externalId warning
2025-12-18 16:26:56 +01:00
Yunwen Zheng 58a026b6a5 RecentlyViewedDashboards: Clear history button (#115519)
* RecentlyViewedDashboards: Clear history button
2025-12-18 10:22:40 -05:00
Kristina Demeshchik 7e5eb46bea Dashboards: Fix text panel content loss during v1 to v2 migration (#115496)
* move content and mode properties to options level

* move to angular section

* Update comments

* handle missing angular text panel

* re-generate test files

* angualr panels tests

* fixing test

* Update output files

* Update output for dev dashboard

* Spread options at the top panel level for migration

* linting issue

---------

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
2025-12-18 09:59:58 -05:00
Kristina Demeshchik 4bcd31b17a Dashboard: change export dropdown placement in sidebar (#115515)
Update export menu placement
2025-12-18 09:59:26 -05:00
Ryan McKinley 71fda4fa42 do not actually write the value 2025-12-18 10:47:07 +03:00
Ryan McKinley b605f464a4 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-18 10:46:18 +03:00
Ryan McKinley a95b28ab19 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-18 09:44:15 +03:00
Ryan McKinley a82253e63a keep nil unless values exist 2025-12-03 12:43:44 +03:00
Ryan McKinley df4f58fa75 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-03 12:21:30 +03:00
Ryan McKinley 614248c63d annotations 2025-12-02 16:28:45 +03:00
Ryan McKinley ba038e2848 remove from legacy folder api 2025-12-02 15:47:47 +03:00
Ryan McKinley 53eead1fa5 another test works 2025-12-02 15:22:57 +03:00
Ryan McKinley 4c190fa6c2 Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-02 15:02:28 +03:00
Ryan McKinley b1ff3eb2f1 remove general folder in legacy api 2025-12-02 14:48:02 +03:00
Ryan McKinley 3cb29d02ad Merge remote-tracking branch 'origin/main' into ensure-folder-annotation-when-supported 2025-12-02 14:23:06 +03:00
Ryan McKinley ff4f2b3926 remove general folder in legacy api 2025-12-02 14:22:46 +03:00
Ryan McKinley 9d5659bfba ensure folder annotation 2025-12-02 13:20:13 +03:00
332 changed files with 24559 additions and 4972 deletions
+1
View File
@@ -425,6 +425,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/public/locales/enterprise/i18next.config.ts @grafana/grafana-frontend-platform
/public/app/core/internationalization/ @grafana/grafana-frontend-platform
/e2e/ @grafana/grafana-frontend-platform
/e2e-playwright/alerting-suite/ @grafana/alerting-frontend
/e2e-playwright/cloud-plugins-suite/ @grafana/partner-datasources
/e2e-playwright/dashboard-new-layouts/ @grafana/dashboards-squad
/e2e-playwright/dashboard-cujs/ @grafana/dashboards-squad
@@ -95,9 +95,11 @@ runs:
- 'nx.json'
- 'tsconfig.json'
- '.yarn/**'
- 'apps/dashboard/pkg/migration/**'
- '${{ inputs.self }}'
e2e:
- 'e2e/**'
- 'e2e-playwright/**'
- '.github/actions/setup-enterprise/**'
- '.github/actions/checkout/**'
- 'emails/**'
@@ -300,15 +300,9 @@
"y": 0
},
"id": 6,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
},
"options": {},
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown",
"pluginVersion": "11.0.0-pre",
"targets": [
{
@@ -71,12 +71,11 @@
"id": 1,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": true,
"spotlight": false,
"gradient": false
},
"orientation": "auto",
@@ -150,12 +149,11 @@
"id": 4,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": true,
"rounded": true,
"spotlight": false,
"gradient": false
},
"orientation": "auto",
@@ -229,12 +227,11 @@
"id": 3,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": false,
"gradient": false
},
"orientation": "auto",
@@ -271,85 +268,6 @@
"title": "Center and bar glow",
"type": "radialbar"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 4,
"x": 12,
"y": 1
},
"id": 5,
"maxDataPoints": 20,
"options": {
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"alias": "1",
"datasource": {
"type": "grafana-testdata-datasource"
},
"max": 100,
"min": 1,
"noise": 22,
"refId": "A",
"scenarioId": "random_walk",
"spread": 22,
"startValue": 1
}
],
"title": "Spotlight",
"type": "radialbar"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
@@ -391,10 +309,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -470,10 +387,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": false,
"spotlight": true,
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -549,10 +465,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": false,
"spotlight": true,
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -641,10 +556,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -720,10 +634,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -799,10 +712,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -878,10 +790,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -974,10 +885,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1053,10 +963,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1132,10 +1041,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1211,10 +1119,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1290,10 +1197,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1386,10 +1292,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1469,10 +1374,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1552,10 +1456,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1641,13 +1544,13 @@
"options": {
"barWidth": 12,
"barWidthFactor": 0.4,
"barShape": "rounded",
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": true
},
"endpointMarker": "glow",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1662,8 +1565,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1730,10 +1632,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1748,8 +1649,7 @@
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": true,
"spotlight": true
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1830,10 +1730,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1848,8 +1747,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1917,9 +1815,6 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"sparkline": false,
"spotlight": true,
"gradient": true
},
"glow": "both",
@@ -1934,10 +1829,10 @@
"segmentCount": 12,
"segmentSpacing": 0.3,
"shape": "circle",
"barShape": "rounded",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2004,10 +1899,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -2022,8 +1916,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2090,10 +1983,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"rounded": true,
"spotlight": true,
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -2108,8 +2000,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -955,8 +955,6 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": false
},
"orientation": "auto",
@@ -743,7 +743,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": true,
"label": "cluster",
@@ -764,7 +766,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": false,
"label": "namespace",
@@ -961,8 +961,12 @@
"hide": "dontHide",
"refresh": "onDashboardLoad",
"skipUrlSync": false,
"datasource": {
"type": "",
"uid": "$datasource"
},
"query": {
"kind": "prometheus",
"kind": "",
"spec": {
"__legacyStringValue": "label_values(up, job)"
}
@@ -988,8 +992,12 @@
"hide": "dontHide",
"refresh": "onDashboardLoad",
"skipUrlSync": false,
"datasource": {
"type": "",
"uid": "$datasource"
},
"query": {
"kind": "prometheus",
"kind": "",
"spec": {
"__legacyStringValue": "label_values(up{job=~\"$cluster\"}, instance)"
}
@@ -978,8 +978,11 @@
"skipUrlSync": false,
"query": {
"kind": "DataQuery",
"group": "prometheus",
"group": "",
"version": "v0",
"datasource": {
"name": "$datasource"
},
"spec": {
"__legacyStringValue": "label_values(up, job)"
}
@@ -1007,8 +1010,11 @@
"skipUrlSync": false,
"query": {
"kind": "DataQuery",
"group": "prometheus",
"group": "",
"version": "v0",
"datasource": {
"name": "$datasource"
},
"spec": {
"__legacyStringValue": "label_values(up{job=~\"$cluster\"}, instance)"
}
@@ -115,7 +115,14 @@
"kind": "logs",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "logs",
"originalOptions": {
"height": 100
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -120,7 +120,14 @@
"group": "logs",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "logs",
"originalOptions": {
"height": 100
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -182,7 +182,20 @@
"kind": "table",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "table",
"originalOptions": {
"grid": {
"max": 100,
"min": 0
},
"legend": true,
"y2_format": "bytes",
"y_format": "short"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -189,7 +189,20 @@
"group": "table",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "table",
"originalOptions": {
"grid": {
"max": 100,
"min": 0
},
"legend": true,
"y2_format": "bytes",
"y_format": "short"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -435,7 +435,29 @@
"kind": "table",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "table",
"originalOptions": {
"styles": [
{
"colors": [
"red",
"yellow",
"green"
],
"pattern": "/.*/",
"thresholds": [
"10",
"20"
],
"unit": "short"
}
],
"table": "table2"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -449,7 +449,29 @@
"group": "table",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "table",
"originalOptions": {
"styles": [
{
"colors": [
"red",
"yellow",
"green"
],
"pattern": "/.*/",
"thresholds": [
"10",
"20"
],
"unit": "short"
}
],
"table": "table2"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -110,7 +110,15 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -115,7 +115,15 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "# Angular Text Panel\n# $constant\n\nFor markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n\n## $text\n\n",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -361,7 +361,15 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "## Data link variables overview\n\nThis dashboard presents variables that one can use when creating *data links*. All links redirect to this dashboard and this panel represents the values that were interpolated in the link that was clicked.\n\n\n#### Series variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$seriesName\u003c/span\u003e\n2. **label.datacenter:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenter\u003c/span\u003e\n3. **label.datacenter.region:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenterRegion\u003c/span\u003e\n\n#### Field variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$fieldName\u003c/span\u003e\n\n#### Value variables\n1. **Time:** \u003cspan style=\"color: orange;\"\u003e$valueTime\u003c/span\u003e\n2. **Numeric:** \u003cspan style=\"color: orange;\"\u003e$valueNumeric\u003c/span\u003e\n3. **Text:** \u003cspan style=\"color: orange;\"\u003e$valueText\u003c/span\u003e\n4. **Calc:** \u003cspan style=\"color: orange;\"\u003e$valueCalc\u003c/span\u003e\n\n",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -372,7 +372,15 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "## Data link variables overview\n\nThis dashboard presents variables that one can use when creating *data links*. All links redirect to this dashboard and this panel represents the values that were interpolated in the link that was clicked.\n\n\n#### Series variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$seriesName\u003c/span\u003e\n2. **label.datacenter:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenter\u003c/span\u003e\n3. **label.datacenter.region:** \u003cspan style=\"color: orange;\"\u003e$labelDatacenterRegion\u003c/span\u003e\n\n#### Field variables\n1. **Name:** \u003cspan style=\"color: orange;\"\u003e$fieldName\u003c/span\u003e\n\n#### Value variables\n1. **Time:** \u003cspan style=\"color: orange;\"\u003e$valueTime\u003c/span\u003e\n2. **Numeric:** \u003cspan style=\"color: orange;\"\u003e$valueNumeric\u003c/span\u003e\n3. **Text:** \u003cspan style=\"color: orange;\"\u003e$valueText\u003c/span\u003e\n4. **Calc:** \u003cspan style=\"color: orange;\"\u003e$valueCalc\u003c/span\u003e\n\n",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -167,7 +167,15 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -174,7 +174,15 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -273,7 +273,15 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -282,7 +282,15 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -296,6 +296,7 @@
}
},
{
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"datasource": {
"type": "grafana-testdata-datasource"
},
@@ -306,15 +307,7 @@
"y": 0
},
"id": 6,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
},
"mode": "markdown",
"pluginVersion": "11.0.0-pre",
"targets": [
{
@@ -1256,13 +1256,13 @@
"spec": {
"pluginVersion": "11.0.0-pre",
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
@@ -1301,13 +1301,13 @@
"version": "11.0.0-pre",
"spec": {
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
@@ -62,6 +62,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -151,6 +157,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -241,6 +253,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -320,6 +338,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -401,6 +425,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -480,6 +510,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -559,6 +595,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -639,6 +681,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -730,6 +778,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -927,6 +981,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -1006,6 +1066,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -1085,6 +1151,12 @@
"spec": {
"pluginVersion": "7.4.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -67,6 +67,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -159,6 +165,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -252,6 +264,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -334,6 +352,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -418,6 +442,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -500,6 +530,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -582,6 +618,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -665,6 +707,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -759,6 +807,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -961,6 +1015,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -1043,6 +1103,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -1125,6 +1191,12 @@
"version": "7.4.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "gauge",
"originalOptions": {
"nullPointMode": "null"
}
},
"baseColor": "#299c46",
"reduceOptions": {
"calcs": [
@@ -77,13 +77,12 @@
"id": 1,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -156,13 +155,12 @@
"id": 4,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -235,13 +233,12 @@
"id": 3,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -277,85 +274,6 @@
"title": "Center and bar glow",
"type": "radialbar"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 4,
"x": 12,
"y": 1
},
"id": 5,
"maxDataPoints": 20,
"options": {
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"alias": "1",
"datasource": {
"type": "grafana-testdata-datasource"
},
"max": 100,
"min": 1,
"noise": 22,
"refId": "A",
"scenarioId": "random_walk",
"spread": 22,
"startValue": 1
}
],
"title": "Spotlight",
"type": "radialbar"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
@@ -393,13 +311,12 @@
"id": 8,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -472,13 +389,12 @@
"id": 22,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -551,13 +467,12 @@
"id": 23,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -643,13 +558,12 @@
"id": 18,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.1,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -722,13 +636,12 @@
"id": 19,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.32,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -801,13 +714,12 @@
"id": 20,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.57,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -880,13 +792,12 @@
"id": 21,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.8,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -976,13 +887,12 @@
"id": 25,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1055,13 +965,12 @@
"id": 26,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1134,13 +1043,12 @@
"id": 29,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1213,13 +1121,12 @@
"id": 30,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1292,13 +1199,12 @@
"id": 28,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1388,13 +1294,12 @@
"id": 32,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1471,13 +1376,12 @@
"id": 34,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1554,13 +1458,12 @@
"id": 33,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1645,15 +1548,15 @@
"id": 9,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"endpointMarker": "glow",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1668,8 +1571,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1731,14 +1633,13 @@
"id": 11,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1754,8 +1655,7 @@
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": true,
"spotlight": true
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1831,14 +1731,13 @@
"id": 13,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1854,8 +1753,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1918,15 +1816,13 @@
"id": 14,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"sparkline": false,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1942,8 +1838,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2005,14 +1900,13 @@
"id": 15,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.84,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -2028,8 +1922,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2091,14 +1984,13 @@
"id": 16,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.66,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -2114,8 +2006,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2160,4 +2051,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -73,13 +73,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -165,14 +164,13 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -188,8 +186,7 @@
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": true,
"spotlight": true
"sparkline": true
},
"fieldConfig": {
"defaults": {
@@ -262,14 +259,13 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -285,8 +281,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -360,15 +355,13 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"sparkline": false,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -384,8 +377,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -459,14 +451,13 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.84,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -482,8 +473,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -556,14 +546,13 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.66,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -579,8 +568,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -653,13 +641,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.1,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -745,13 +732,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.32,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -837,13 +823,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.57,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -929,13 +914,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.8,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1021,13 +1005,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1113,13 +1096,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1201,13 +1183,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1293,13 +1274,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1385,13 +1365,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1477,13 +1456,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1573,13 +1551,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1661,13 +1638,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1753,13 +1729,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1849,13 +1824,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1945,13 +1919,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -2045,105 +2018,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false
},
"fieldConfig": {
"defaults": {
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-5": {
"kind": "Panel",
"spec": {
"id": 5,
"title": "Spotlight",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "grafana-testdata-datasource",
"spec": {
"alias": "1",
"max": 100,
"min": 1,
"noise": 22,
"scenarioId": "random_walk",
"spread": 22,
"startValue": 1
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {
"maxDataPoints": 20
}
}
},
"vizConfig": {
"kind": "radialbar",
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -2229,13 +2109,12 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -2321,15 +2200,15 @@
"spec": {
"pluginVersion": "13.0.0-pre",
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"endpointMarker": "glow",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -2344,8 +2223,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -2429,19 +2307,6 @@
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 0,
"width": 4,
"height": 6,
"element": {
"kind": "ElementReference",
"name": "panel-5"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
@@ -2826,4 +2691,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -77,13 +77,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -172,14 +171,13 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -195,8 +193,7 @@
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": true,
"spotlight": true
"sparkline": true
},
"fieldConfig": {
"defaults": {
@@ -272,14 +269,13 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -295,8 +291,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -373,15 +368,13 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"sparkline": false,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -397,8 +390,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -475,14 +467,13 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.84,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -498,8 +489,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -575,14 +565,13 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.66,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -598,8 +587,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -675,13 +663,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.1,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -770,13 +757,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.32,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -865,13 +851,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.57,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -960,13 +945,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.8,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1055,13 +1039,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1150,13 +1133,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1241,13 +1223,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1336,13 +1317,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1431,13 +1411,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1526,13 +1505,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1625,13 +1603,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1716,13 +1693,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1811,13 +1787,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1910,13 +1885,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -2009,13 +1983,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -2112,108 +2085,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false
},
"fieldConfig": {
"defaults": {
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-5": {
"kind": "Panel",
"spec": {
"id": 5,
"title": "Spotlight",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "grafana-testdata-datasource",
"version": "v0",
"spec": {
"alias": "1",
"max": 100,
"min": 1,
"noise": 22,
"scenarioId": "random_walk",
"spread": 22,
"startValue": 1
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {
"maxDataPoints": 20
}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "radialbar",
"version": "13.0.0-pre",
"spec": {
"options": {
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -2302,13 +2179,12 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -2397,15 +2273,15 @@
"version": "13.0.0-pre",
"spec": {
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"endpointMarker": "glow",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -2420,8 +2296,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"fieldConfig": {
"defaults": {
@@ -2505,19 +2380,6 @@
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 0,
"width": 4,
"height": 6,
"element": {
"kind": "ElementReference",
"name": "panel-5"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
@@ -2902,4 +2764,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -961,9 +961,7 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1175,4 +1173,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -864,9 +864,7 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1620,4 +1618,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -901,9 +901,7 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1672,4 +1670,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -412,7 +412,17 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Should be a long line connecting the null region in the `connected` mode, and in zero it should just be a line with zero value at the null points. ",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -456,7 +466,17 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Stacking values on top of nulls, should treat the null values as zero. ",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -500,7 +520,17 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Stacking when all values are null should leave a gap in the graph",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -1681,7 +1711,17 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -2061,7 +2101,17 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Just verify that the tooltip time has millisecond resolution ",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -2105,7 +2155,17 @@
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Verify that axis labels look ok",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -429,7 +429,17 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Should be a long line connecting the null region in the `connected` mode, and in zero it should just be a line with zero value at the null points. ",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -475,7 +485,17 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Stacking values on top of nulls, should treat the null values as zero. ",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -521,7 +541,17 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Stacking when all values are null should leave a gap in the graph",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -1779,7 +1809,17 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Left is showing null between values for a normal line graph and staircase graph. Orphaned data points should be rendered as points",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -2172,7 +2212,17 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Just verify that the tooltip time has millisecond resolution ",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -2218,7 +2268,17 @@
"group": "text",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "text",
"originalOptions": {
"content": "Verify that axis labels look ok",
"editable": true,
"error": false,
"mode": "markdown"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -74,7 +74,44 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateViridis",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"tooltipDecimals": 4,
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 2,
"format": "areaM2",
"logBase": 1,
"show": true
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -116,7 +153,46 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {
"cardRound": 50
},
"color": {
"cardColor": "#1F60C4",
"colorScale": "sqrt",
"colorScheme": "interpolateOranges",
"exponent": 0.5,
"mode": "opacity"
},
"dataFormat": "tsbuckets",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": false
},
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 1,
"format": "kwatt",
"logBase": 1,
"show": true,
"width": "100"
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -158,7 +234,44 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#1F60C4",
"colorScale": "sqrt",
"colorScheme": "interpolateOranges",
"exponent": 0.5,
"mode": "opacity"
},
"dataFormat": "tsbuckets",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": true,
"tooltip": {
"show": true,
"showHistogram": false
},
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 1,
"format": "kwatt",
"logBase": 1,
"show": true,
"width": "100"
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -204,7 +317,46 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateViridis",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"tooltipDecimals": 4,
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 2,
"format": "areaM2",
"logBase": 1,
"max": "50",
"min": "20",
"show": true
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -246,7 +398,44 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateBuGn",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": true,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"xAxis": {
"show": true
},
"xBucketNumber": 10,
"yAxis": {
"format": "short",
"logBase": 2,
"show": true,
"splitFactor": 2
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -288,7 +477,44 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateBuGn",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": true,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"xAxis": {
"show": true
},
"xBucketNumber": 10,
"yAxis": {
"format": "short",
"logBase": 10,
"show": true,
"splitFactor": 5
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -78,7 +78,44 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateViridis",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"tooltipDecimals": 4,
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 2,
"format": "areaM2",
"logBase": 1,
"show": true
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -123,7 +160,46 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {
"cardRound": 50
},
"color": {
"cardColor": "#1F60C4",
"colorScale": "sqrt",
"colorScheme": "interpolateOranges",
"exponent": 0.5,
"mode": "opacity"
},
"dataFormat": "tsbuckets",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": false
},
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 1,
"format": "kwatt",
"logBase": 1,
"show": true,
"width": "100"
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -168,7 +244,44 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#1F60C4",
"colorScale": "sqrt",
"colorScheme": "interpolateOranges",
"exponent": 0.5,
"mode": "opacity"
},
"dataFormat": "tsbuckets",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": true,
"tooltip": {
"show": true,
"showHistogram": false
},
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 1,
"format": "kwatt",
"logBase": 1,
"show": true,
"width": "100"
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -216,7 +329,46 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateViridis",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"tooltipDecimals": 4,
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 2,
"format": "areaM2",
"logBase": 1,
"max": "50",
"min": "20",
"show": true
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -261,7 +413,44 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateBuGn",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": true,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"xAxis": {
"show": true
},
"xBucketNumber": 10,
"yAxis": {
"format": "short",
"logBase": 2,
"show": true,
"splitFactor": 2
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -306,7 +495,44 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateBuGn",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": true,
"highlightCards": true,
"legend": {
"show": true
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": true
},
"xAxis": {
"show": true
},
"xBucketNumber": 10,
"yAxis": {
"format": "short",
"logBase": 10,
"show": true,
"splitFactor": 5
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -665,7 +665,42 @@
"kind": "heatmap",
"spec": {
"pluginVersion": "",
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateOranges",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": false
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": false
},
"xAxis": {
"show": true
},
"yAxis": {
"format": "short",
"logBase": 1,
"show": true
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -691,7 +691,42 @@
"group": "heatmap",
"version": "",
"spec": {
"options": {},
"options": {
"__angularMigration": {
"autoMigrateFrom": "heatmap",
"originalOptions": {
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateOranges",
"exponent": 0.5,
"mode": "spectrum"
},
"dataFormat": "timeseries",
"heatmap": {},
"hideZeroBuckets": false,
"highlightCards": true,
"legend": {
"show": false
},
"reverseYBuckets": false,
"tooltip": {
"show": true,
"showHistogram": false
},
"xAxis": {
"show": true
},
"yAxis": {
"format": "short",
"logBase": 1,
"show": true
},
"yBucketBound": "auto"
}
}
},
"fieldConfig": {
"defaults": {},
"overrides": []
@@ -56,6 +56,14 @@
"spec": {
"pluginVersion": "9.0.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"panel-tests"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -94,6 +102,15 @@
"spec": {
"pluginVersion": "9.0.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"gdev",
"demo"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -133,6 +150,15 @@
"spec": {
"pluginVersion": "9.0.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"templating",
"gdev"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -172,6 +198,15 @@
"spec": {
"pluginVersion": "9.0.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"gdev",
"datasource-test"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -211,6 +246,12 @@
"spec": {
"pluginVersion": "9.0.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": []
}
},
"maxItems": 100,
"query": "",
"showHeadings": true,
@@ -247,6 +288,15 @@
"spec": {
"pluginVersion": "9.0.0-pre",
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"gdev",
"demo"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -58,6 +58,14 @@
"version": "9.0.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"panel-tests"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -97,6 +105,15 @@
"version": "9.0.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"gdev",
"demo"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -137,6 +154,15 @@
"version": "9.0.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"templating",
"gdev"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -177,6 +203,15 @@
"version": "9.0.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"gdev",
"datasource-test"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -217,6 +252,12 @@
"version": "9.0.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": []
}
},
"maxItems": 100,
"query": "",
"showHeadings": true,
@@ -254,6 +295,15 @@
"version": "9.0.0-pre",
"spec": {
"options": {
"__angularMigration": {
"autoMigrateFrom": "dashlist",
"originalOptions": {
"tags": [
"gdev",
"demo"
]
}
},
"maxItems": 1000,
"query": "",
"showHeadings": false,
@@ -2,6 +2,7 @@ package conversion
import (
"context"
"strings"
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/apiserver/pkg/endpoints/request"
@@ -79,5 +80,57 @@ func ConvertDashboard_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard,
return schemaversion.NewMigrationError(err.Error(), schemaversion.GetSchemaVersion(in.Spec.Object), schemaversion.LATEST_VERSION, "Convert_V0_to_V1")
}
// Normalize template variable datasources from string to object format
// This handles legacy dashboards where query variables have datasource: "$datasource" (string)
// instead of datasource: { uid: "$datasource" } (object)
// our migration pipeline in v36 doesn't address because this was not addressed historically
// in DashboardMigrator - see public/app/features/dashboard/state/DashboardMigrator.ts#L607
// Which means that we have schemaVersion: 42 dashboards where datasource variable references are still strings
normalizeTemplateVariableDatasources(out.Spec.Object)
return nil
}
// normalizeTemplateVariableDatasources converts template variable string datasources to object format.
// Legacy dashboards may have query variables with datasource: "$datasource" (string).
// This normalizes them to datasource: { uid: "$datasource" } for consistent V1→V2 conversion.
func normalizeTemplateVariableDatasources(dashboard map[string]interface{}) {
templating, ok := dashboard["templating"].(map[string]interface{})
if !ok {
return
}
list, ok := templating["list"].([]interface{})
if !ok {
return
}
for _, variable := range list {
varMap, ok := variable.(map[string]interface{})
if !ok {
continue
}
varType, _ := varMap["type"].(string)
if varType != "query" {
continue
}
ds := varMap["datasource"]
if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) {
// Convert string template variable reference to object format
varMap["datasource"] = map[string]interface{}{
"uid": dsStr,
}
}
}
}
// isTemplateVariableRef checks if a string is a Grafana template variable reference.
// Template variables can be in the form: $varname or ${varname}
func isTemplateVariableRef(s string) bool {
if s == "" {
return false
}
return strings.HasPrefix(s, "$") || strings.HasPrefix(s, "${")
}
@@ -1185,6 +1185,10 @@ func buildQueryVariable(ctx context.Context, varMap map[string]interface{}, comm
// If no UID and no type, use default
datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider)
}
} else if dsStr, ok := datasource.(string); ok && isTemplateVariable(dsStr) {
// Handle datasource variable reference (e.g., "$datasource")
// Only process template variables - other string values are not supported in V2 format
datasourceUID = dsStr
} else {
datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider)
}
@@ -1532,6 +1536,10 @@ func buildAdhocVariable(ctx context.Context, varMap map[string]interface{}, comm
// If no UID and no type, use default
datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider)
}
} else if dsStr, ok := datasource.(string); ok && isTemplateVariable(dsStr) {
// Handle datasource variable reference (e.g., "$datasource")
// Only process template variables - other string values are not supported in V2 format
datasourceUID = dsStr
} else {
datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider)
}
@@ -1709,6 +1717,10 @@ func buildGroupByVariable(ctx context.Context, varMap map[string]interface{}, co
// Resolve Grafana datasource UID when type is "datasource" and UID is empty
datasourceUID = resolveGrafanaDatasourceUID(datasourceType, datasourceUID)
} else if dsStr, ok := datasource.(string); ok && isTemplateVariable(dsStr) {
// Handle datasource variable reference (e.g., "$datasource")
// Only process template variables - other string values are not supported in V2 format
datasourceUID = dsStr
} else {
datasourceType = getDefaultDatasourceType(ctx, dsIndexProvider)
}
@@ -2296,20 +2308,24 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo
// We check two cases:
// 1. Panel already has autoMigrateFrom set (from v0→v1 migration) - panel type already converted
// 2. Panel type is a known Angular panel - need to convert type AND set autoMigrateFrom
// 3. Panel has original options - need to set autoMigrateFrom and originalOptions
autoMigrateFrom, hasAutoMigrateFrom := panelMap["autoMigrateFrom"].(string)
originalOptions := extractAngularOptions(panelMap)
if !hasAutoMigrateFrom || autoMigrateFrom == "" {
// Check if panel type is an Angular type that needs migration
if newType := getAngularPanelMigration(panelType, panelMap); newType != "" {
autoMigrateFrom = panelType // Original Angular type
panelType = newType // New modern type
} else if len(originalOptions) > 0 {
autoMigrateFrom = panelType
}
}
if autoMigrateFrom != "" {
options["__angularMigration"] = map[string]interface{}{
"autoMigrateFrom": autoMigrateFrom,
"originalOptions": extractAngularOptions(panelMap),
"originalOptions": originalOptions,
}
}
@@ -290,6 +290,7 @@
}
},
{
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"datasource": {
"type": "grafana-testdata-datasource"
},
@@ -300,15 +301,7 @@
"y": 0
},
"id": 6,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "# Graph panel \u003e\u003e Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
},
"mode": "markdown",
"pluginVersion": "11.0.0-pre",
"targets": [
{
@@ -75,10 +75,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -154,10 +153,9 @@
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -233,10 +231,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -305,85 +302,6 @@
"x": 12,
"y": 1
},
"id": 5,
"maxDataPoints": 20,
"options": {
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"alias": "1",
"datasource": {
"type": "grafana-testdata-datasource"
},
"max": 100,
"min": 1,
"noise": 22,
"refId": "A",
"scenarioId": "random_walk",
"spread": 22,
"startValue": 1
}
],
"title": "Spotlight",
"type": "radialbar"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 4,
"x": 16,
"y": 1
},
"id": 8,
"maxDataPoints": 20,
"options": {
@@ -391,10 +309,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -460,8 +377,8 @@
"gridPos": {
"h": 6,
"w": 4,
"x": 0,
"y": 7
"x": 16,
"y": 1
},
"id": 22,
"maxDataPoints": 20,
@@ -470,10 +387,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -539,8 +455,8 @@
"gridPos": {
"h": 6,
"w": 4,
"x": 4,
"y": 7
"x": 20,
"y": 1
},
"id": 23,
"maxDataPoints": 20,
@@ -549,10 +465,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -593,7 +508,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 13
"y": 7
},
"id": 17,
"panels": [],
@@ -630,9 +545,9 @@
},
"gridPos": {
"h": 6,
"w": 5,
"w": 4,
"x": 0,
"y": 14
"y": 8
},
"id": 18,
"maxDataPoints": 20,
@@ -641,10 +556,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -709,9 +623,9 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 5,
"y": 14
"w": 4,
"x": 4,
"y": 8
},
"id": 19,
"maxDataPoints": 20,
@@ -720,10 +634,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -788,9 +701,9 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 10,
"y": 14
"w": 4,
"x": 8,
"y": 8
},
"id": 20,
"maxDataPoints": 20,
@@ -799,10 +712,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -867,9 +779,9 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 15,
"y": 14
"w": 4,
"x": 12,
"y": 8
},
"id": 21,
"maxDataPoints": 20,
@@ -878,10 +790,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"barShape": "rounded",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -922,7 +833,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 20
"y": 14
},
"id": 24,
"panels": [],
@@ -963,9 +874,9 @@
},
"gridPos": {
"h": 6,
"w": 6,
"w": 4,
"x": 0,
"y": 21
"y": 15
},
"id": 25,
"maxDataPoints": 20,
@@ -974,10 +885,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1042,9 +952,9 @@
},
"gridPos": {
"h": 6,
"w": 6,
"x": 6,
"y": 21
"w": 4,
"x": 4,
"y": 15
},
"id": 26,
"maxDataPoints": 20,
@@ -1053,10 +963,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1121,9 +1030,9 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 12,
"y": 21
"w": 4,
"x": 8,
"y": 15
},
"id": 29,
"maxDataPoints": 20,
@@ -1132,10 +1041,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1199,10 +1107,10 @@
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 0,
"y": 27
"h": 6,
"w": 4,
"x": 12,
"y": 15
},
"id": 30,
"maxDataPoints": 20,
@@ -1211,10 +1119,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1278,10 +1185,10 @@
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 6,
"y": 27
"h": 6,
"w": 4,
"x": 16,
"y": 15
},
"id": 28,
"maxDataPoints": 20,
@@ -1290,10 +1197,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1330,7 +1236,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 34
"y": 21
},
"id": 31,
"panels": [],
@@ -1377,7 +1283,7 @@
"h": 10,
"w": 7,
"x": 0,
"y": 35
"y": 22
},
"id": 32,
"maxDataPoints": 20,
@@ -1386,10 +1292,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1460,7 +1365,7 @@
"h": 10,
"w": 7,
"x": 7,
"y": 35
"y": 22
},
"id": 34,
"maxDataPoints": 20,
@@ -1469,10 +1374,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1543,7 +1447,7 @@
"h": 10,
"w": 6,
"x": 14,
"y": 35
"y": 22
},
"id": 33,
"maxDataPoints": 20,
@@ -1552,10 +1456,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -1592,7 +1495,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 45
"y": 32
},
"id": 6,
"panels": [],
@@ -1633,20 +1536,20 @@
"h": 6,
"w": 24,
"x": 0,
"y": 46
"y": 33
},
"id": 9,
"maxDataPoints": 20,
"options": {
"barWidth": 12,
"barWidthFactor": 0.4,
"barShape": "rounded",
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"endpointMarker": "glow",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1661,8 +1564,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1717,7 +1619,7 @@
"h": 6,
"w": 24,
"x": 0,
"y": 52
"y": 39
},
"id": 11,
"maxDataPoints": 20,
@@ -1727,10 +1629,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1745,8 +1646,7 @@
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": true,
"spotlight": true
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1773,7 +1673,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 58
"y": 45
},
"id": 12,
"panels": [],
@@ -1815,7 +1715,7 @@
"h": 7,
"w": 4,
"x": 0,
"y": 59
"y": 46
},
"id": 13,
"maxDataPoints": 20,
@@ -1825,10 +1725,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1843,8 +1742,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1900,7 +1798,7 @@
"h": 7,
"w": 5,
"x": 4,
"y": 59
"y": 46
},
"id": 14,
"maxDataPoints": 20,
@@ -1910,10 +1808,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1928,8 +1825,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1984,7 +1880,7 @@
"h": 7,
"w": 5,
"x": 9,
"y": 59
"y": 46
},
"id": 15,
"maxDataPoints": 20,
@@ -1994,10 +1890,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -2012,8 +1907,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2068,7 +1962,7 @@
"h": 7,
"w": 6,
"x": 14,
"y": 59
"y": 46
},
"id": 16,
"maxDataPoints": 20,
@@ -2078,10 +1972,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"barShape": "rounded",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -2096,8 +1989,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2124,7 +2016,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 66
"y": 53
},
"id": 35,
"panels": [],
@@ -2155,10 +2047,10 @@
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"h": 5,
"w": 12,
"x": 0,
"y": 67
"y": 54
},
"id": 36,
"options": {
@@ -2166,10 +2058,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -2223,10 +2114,10 @@
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 67
"h": 5,
"w": 12,
"x": 12,
"y": 54
},
"id": 37,
"options": {
@@ -2234,10 +2125,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"barShape": "flat",
"orientation": "auto",
"reduceOptions": {
"calcs": [
@@ -2279,4 +2169,4 @@
"title": "Panel tests - Gauge (new)",
"uid": "panel-tests-gauge-new",
"weekStart": ""
}
}
@@ -955,9 +955,7 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1162,4 +1160,4 @@
"title": "Panel tests - Old gauge to new",
"uid": "panel-tests-old-gauge-to-new",
"weekStart": ""
}
}
@@ -654,7 +654,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": true,
"label": "cluster",
@@ -677,7 +679,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": false,
"label": "namespace",
@@ -737,7 +737,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": true,
"label": "cluster",
@@ -758,7 +760,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": false,
"label": "namespace",
@@ -717,7 +717,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": true,
"label": "cluster",
@@ -739,7 +741,9 @@
"text": "prod",
"value": "prod"
},
"datasource": "$datasource",
"datasource": {
"uid": "$datasource"
},
"hide": 0,
"includeAll": false,
"label": "namespace",
@@ -0,0 +1,22 @@
package auth
import (
"context"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
//go:generate mockery --name AccessChecker --structname MockAccessChecker --inpackage --filename access_checker_mock.go --with-expecter
// AccessChecker provides access control checks with optional role-based fallback.
type AccessChecker interface {
// Check performs an access check and returns nil if allowed, or an appropriate
// API error if denied. If req.Namespace is empty, it will be filled from the
// identity's namespace.
Check(ctx context.Context, req authlib.CheckRequest, folder string) error
// WithFallbackRole returns an AccessChecker configured with the specified fallback role.
// Whether the fallback is actually applied depends on the implementation.
WithFallbackRole(role identity.RoleType) AccessChecker
}
@@ -0,0 +1,135 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package auth
import (
context "context"
identity "github.com/grafana/grafana/pkg/apimachinery/identity"
mock "github.com/stretchr/testify/mock"
types "github.com/grafana/authlib/types"
)
// MockAccessChecker is an autogenerated mock type for the AccessChecker type
type MockAccessChecker struct {
mock.Mock
}
type MockAccessChecker_Expecter struct {
mock *mock.Mock
}
func (_m *MockAccessChecker) EXPECT() *MockAccessChecker_Expecter {
return &MockAccessChecker_Expecter{mock: &_m.Mock}
}
// Check provides a mock function with given fields: ctx, req, folder
func (_m *MockAccessChecker) Check(ctx context.Context, req types.CheckRequest, folder string) error {
ret := _m.Called(ctx, req, folder)
if len(ret) == 0 {
panic("no return value specified for Check")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, types.CheckRequest, string) error); ok {
r0 = rf(ctx, req, folder)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAccessChecker_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
type MockAccessChecker_Check_Call struct {
*mock.Call
}
// Check is a helper method to define mock.On call
// - ctx context.Context
// - req types.CheckRequest
// - folder string
func (_e *MockAccessChecker_Expecter) Check(ctx interface{}, req interface{}, folder interface{}) *MockAccessChecker_Check_Call {
return &MockAccessChecker_Check_Call{Call: _e.mock.On("Check", ctx, req, folder)}
}
func (_c *MockAccessChecker_Check_Call) Run(run func(ctx context.Context, req types.CheckRequest, folder string)) *MockAccessChecker_Check_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(types.CheckRequest), args[2].(string))
})
return _c
}
func (_c *MockAccessChecker_Check_Call) Return(_a0 error) *MockAccessChecker_Check_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAccessChecker_Check_Call) RunAndReturn(run func(context.Context, types.CheckRequest, string) error) *MockAccessChecker_Check_Call {
_c.Call.Return(run)
return _c
}
// WithFallbackRole provides a mock function with given fields: role
func (_m *MockAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker {
ret := _m.Called(role)
if len(ret) == 0 {
panic("no return value specified for WithFallbackRole")
}
var r0 AccessChecker
if rf, ok := ret.Get(0).(func(identity.RoleType) AccessChecker); ok {
r0 = rf(role)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(AccessChecker)
}
}
return r0
}
// MockAccessChecker_WithFallbackRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithFallbackRole'
type MockAccessChecker_WithFallbackRole_Call struct {
*mock.Call
}
// WithFallbackRole is a helper method to define mock.On call
// - role identity.RoleType
func (_e *MockAccessChecker_Expecter) WithFallbackRole(role interface{}) *MockAccessChecker_WithFallbackRole_Call {
return &MockAccessChecker_WithFallbackRole_Call{Call: _e.mock.On("WithFallbackRole", role)}
}
func (_c *MockAccessChecker_WithFallbackRole_Call) Run(run func(role identity.RoleType)) *MockAccessChecker_WithFallbackRole_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(identity.RoleType))
})
return _c
}
func (_c *MockAccessChecker_WithFallbackRole_Call) Return(_a0 AccessChecker) *MockAccessChecker_WithFallbackRole_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAccessChecker_WithFallbackRole_Call) RunAndReturn(run func(identity.RoleType) AccessChecker) *MockAccessChecker_WithFallbackRole_Call {
_c.Call.Return(run)
return _c
}
// NewMockAccessChecker creates a new instance of MockAccessChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAccessChecker(t interface {
mock.TestingT
Cleanup(func())
}) *MockAccessChecker {
mock := &MockAccessChecker{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+46 -14
View File
@@ -1,3 +1,4 @@
// Package auth provides authentication utilities for the provisioning API.
package auth
import (
@@ -6,7 +7,6 @@ import (
"net/http"
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
utilnet "k8s.io/apimachinery/pkg/util/net"
)
@@ -15,29 +15,61 @@ type tokenExchanger interface {
Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error)
}
// RoundTripper injects an exchanged access token for the provisioning API into outgoing requests.
type RoundTripper struct {
client tokenExchanger
transport http.RoundTripper
audience string
// RoundTripperOption configures optional behavior for the RoundTripper.
type RoundTripperOption func(*RoundTripper)
// ExtraAudience appends an additional audience to the token exchange request.
//
// This is primarily used by operators connecting to the multitenant aggregator,
// where the token must include both the target API server's audience (e.g., dashboards,
// folders) and the provisioning group audience. The provisioning group audience is
// required so that the token passes the enforceManagerProperties check, which prevents
// unauthorized updates to provisioned resources.
//
// Example:
//
// authrt.NewRoundTripper(client, rt, "dashboards.grafana.app", authrt.ExtraAudience("provisioning.grafana.app"))
func ExtraAudience(audience string) RoundTripperOption {
return func(rt *RoundTripper) {
rt.extraAudience = audience
}
}
// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request
// and forwards the request to the provided base transport.
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper {
return &RoundTripper{
// RoundTripper is an http.RoundTripper that performs token exchange before each request.
// It exchanges the service's credentials for an access token scoped to the configured
// audience(s), then injects that token into the outgoing request's X-Access-Token header.
type RoundTripper struct {
client tokenExchanger
transport http.RoundTripper
audience string
extraAudience string
}
// NewRoundTripper creates a RoundTripper that exchanges tokens for each outgoing request.
//
// Parameters:
// - tokenExchangeClient: the client used to exchange credentials for access tokens
// - base: the underlying transport to delegate requests to after token injection
// - audience: the primary audience for the token (typically the target API server's group)
// - opts: optional configuration (e.g., ExtraAudience to include additional audiences)
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string, opts ...RoundTripperOption) *RoundTripper {
rt := &RoundTripper{
client: tokenExchangeClient,
transport: base,
audience: audience,
}
for _, opt := range opts {
opt(rt)
}
return rt
}
// RoundTrip exchanges credentials for an access token and injects it into the request.
// The token is scoped to all configured audiences and the wildcard namespace ("*").
func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// when we want to write resources with the provisioning API, the audience needs to include provisioning
// so that it passes the check in enforceManagerProperties, which prevents others from updating provisioned resources
audiences := []string{t.audience}
if t.audience != v0alpha1.GROUP {
audiences = append(audiences, v0alpha1.GROUP)
if t.extraAudience != "" && t.extraAudience != t.audience {
audiences = append(audiences, t.extraAudience)
}
tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{
@@ -71,16 +71,29 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
tests := []struct {
name string
audience string
extraAudience string
wantAudiences []string
}{
{
name: "adds group when custom audience",
name: "uses only provided audience by default",
audience: "example-audience",
wantAudiences: []string{"example-audience"},
},
{
name: "uses only group audience by default",
audience: v0alpha1.GROUP,
wantAudiences: []string{v0alpha1.GROUP},
},
{
name: "extra audience adds provisioning group",
audience: "example-audience",
extraAudience: v0alpha1.GROUP,
wantAudiences: []string{"example-audience", v0alpha1.GROUP},
},
{
name: "no duplicate when group audience",
name: "extra audience no duplicate when same as primary",
audience: v0alpha1.GROUP,
extraAudience: v0alpha1.GROUP,
wantAudiences: []string{v0alpha1.GROUP},
},
}
@@ -88,11 +101,15 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fx := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}}
var opts []RoundTripperOption
if tt.extraAudience != "" {
opts = append(opts, ExtraAudience(tt.extraAudience))
}
tr := NewRoundTripper(fx, roundTripperFunc(func(_ *http.Request) (*http.Response, error) {
rr := httptest.NewRecorder()
rr.WriteHeader(http.StatusOK)
return rr.Result(), nil
}), tt.audience)
}), tt.audience, opts...)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil)
resp, err := tr.RoundTrip(req)
@@ -0,0 +1,153 @@
package auth
import (
"context"
"fmt"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
// sessionAccessChecker implements AccessChecker using Grafana session identity.
type sessionAccessChecker struct {
inner authlib.AccessChecker
fallbackRole identity.RoleType
}
// NewSessionAccessChecker creates an AccessChecker that gets identity from Grafana
// sessions via GetRequester(ctx). Supports optional role-based fallback via
// WithFallbackRole for backwards compatibility.
func NewSessionAccessChecker(inner authlib.AccessChecker) AccessChecker {
return &sessionAccessChecker{
inner: inner,
fallbackRole: "",
}
}
// WithFallbackRole returns a new AccessChecker with the specified fallback role.
func (c *sessionAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker {
return &sessionAccessChecker{
inner: c.inner,
fallbackRole: role,
}
}
// Check performs an access check with optional role-based fallback.
// Returns nil if access is allowed, or an appropriate API error if denied.
func (c *sessionAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error {
logger := logging.FromContext(ctx).With("logger", "sessionAccessChecker")
// Get identity from Grafana session
requester, err := identity.GetRequester(ctx)
if err != nil {
logger.Debug("failed to get requester",
"resource", req.Resource,
"verb", req.Verb,
"error", err.Error(),
)
return apierrors.NewUnauthorized(fmt.Sprintf("failed to get requester: %v", err))
}
logger.Debug("checking access",
"identityType", requester.GetIdentityType(),
"orgRole", requester.GetOrgRole(),
"namespace", requester.GetNamespace(),
"resource", req.Resource,
"verb", req.Verb,
"group", req.Group,
"name", req.Name,
"folder", folder,
"fallbackRole", c.fallbackRole,
)
// Fill in namespace from identity if not provided
if req.Namespace == "" {
req.Namespace = requester.GetNamespace()
}
// Perform the access check
rsp, err := c.inner.Check(ctx, requester, req, folder)
// Build the GroupResource for error messages
gr := schema.GroupResource{Group: req.Group, Resource: req.Resource}
// No fallback configured, return result directly
if c.fallbackRole == "" {
if err != nil {
logger.Debug("access check error (no fallback)",
"resource", req.Resource,
"verb", req.Verb,
"error", err.Error(),
)
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
}
if !rsp.Allowed {
logger.Debug("access check denied (no fallback)",
"resource", req.Resource,
"verb", req.Verb,
"group", req.Group,
"allowed", rsp.Allowed,
)
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied"))
}
logger.Debug("access allowed",
"resource", req.Resource,
"verb", req.Verb,
)
return nil
}
// Fallback is configured - apply fallback logic
if err != nil {
if requester.GetOrgRole().Includes(c.fallbackRole) {
logger.Debug("access allowed via role fallback (after error)",
"resource", req.Resource,
"verb", req.Verb,
"fallbackRole", c.fallbackRole,
"orgRole", requester.GetOrgRole(),
)
return nil // Fallback succeeded
}
logger.Debug("access check error (fallback failed)",
"resource", req.Resource,
"verb", req.Verb,
"error", err.Error(),
"fallbackRole", c.fallbackRole,
"orgRole", requester.GetOrgRole(),
)
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
}
if rsp.Allowed {
logger.Debug("access allowed",
"resource", req.Resource,
"verb", req.Verb,
)
return nil
}
// Fall back to role for backwards compatibility
if requester.GetOrgRole().Includes(c.fallbackRole) {
logger.Debug("access allowed via role fallback",
"resource", req.Resource,
"verb", req.Verb,
"fallbackRole", c.fallbackRole,
"orgRole", requester.GetOrgRole(),
)
return nil // Fallback succeeded
}
logger.Debug("access denied (fallback role not met)",
"resource", req.Resource,
"verb", req.Verb,
"group", req.Group,
"fallbackRole", c.fallbackRole,
"orgRole", requester.GetOrgRole(),
)
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s role is required", strings.ToLower(string(c.fallbackRole))))
}
@@ -0,0 +1,244 @@
package auth
import (
"context"
"errors"
"testing"
apierrors "k8s.io/apimachinery/pkg/api/errors"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockRequester implements identity.Requester for testing.
type mockRequester struct {
identity.Requester
orgRole identity.RoleType
identityType authlib.IdentityType
namespace string
}
func (m *mockRequester) GetOrgRole() identity.RoleType {
return m.orgRole
}
func (m *mockRequester) GetIdentityType() authlib.IdentityType {
return m.identityType
}
func (m *mockRequester) GetNamespace() string {
return m.namespace
}
func TestSessionAccessChecker_Check(t *testing.T) {
ctx := context.Background()
req := authlib.CheckRequest{
Verb: "get",
Group: "provisioning.grafana.app",
Resource: "repositories",
Name: "test-repo",
Namespace: "default",
}
tests := []struct {
name string
fallbackRole identity.RoleType
innerResponse authlib.CheckResponse
innerErr error
requester *mockRequester
expectAllow bool
}{
{
name: "allowed by checker",
fallbackRole: identity.RoleAdmin,
innerResponse: authlib.CheckResponse{Allowed: true},
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
expectAllow: true,
},
{
name: "denied by checker, fallback to admin role succeeds",
fallbackRole: identity.RoleAdmin,
innerResponse: authlib.CheckResponse{Allowed: false},
requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser},
expectAllow: true,
},
{
name: "denied by checker, fallback to admin role fails for viewer",
fallbackRole: identity.RoleAdmin,
innerResponse: authlib.CheckResponse{Allowed: false},
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
expectAllow: false,
},
{
name: "error from checker, fallback to admin role succeeds",
fallbackRole: identity.RoleAdmin,
innerErr: errors.New("access check failed"),
requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser},
expectAllow: true,
},
{
name: "error from checker, fallback fails for viewer",
fallbackRole: identity.RoleAdmin,
innerErr: errors.New("access check failed"),
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
expectAllow: false,
},
{
name: "denied, editor fallback succeeds for editor",
fallbackRole: identity.RoleEditor,
innerResponse: authlib.CheckResponse{Allowed: false},
requester: &mockRequester{orgRole: identity.RoleEditor, identityType: authlib.TypeUser},
expectAllow: true,
},
{
name: "denied, editor fallback fails for viewer",
fallbackRole: identity.RoleEditor,
innerResponse: authlib.CheckResponse{Allowed: false},
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
expectAllow: false,
},
{
name: "no fallback configured, denied stays denied",
fallbackRole: "", // no fallback
innerResponse: authlib.CheckResponse{Allowed: false},
requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser},
expectAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockInnerAccessChecker{
response: tt.innerResponse,
err: tt.innerErr,
}
checker := NewSessionAccessChecker(mock)
if tt.fallbackRole != "" {
checker = checker.WithFallbackRole(tt.fallbackRole)
}
// Add requester to context
testCtx := identity.WithRequester(ctx, tt.requester)
err := checker.Check(testCtx, req, "")
if tt.expectAllow {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err)
}
})
}
}
func TestSessionAccessChecker_NoRequester(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: true},
}
checker := NewSessionAccessChecker(mock)
err := checker.Check(context.Background(), authlib.CheckRequest{}, "")
require.Error(t, err)
assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error")
}
func TestSessionAccessChecker_WithFallbackRole_ImmutableOriginal(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: false},
}
original := NewSessionAccessChecker(mock)
withAdmin := original.WithFallbackRole(identity.RoleAdmin)
withEditor := original.WithFallbackRole(identity.RoleEditor)
ctx := identity.WithRequester(context.Background(), &mockRequester{
orgRole: identity.RoleEditor,
identityType: authlib.TypeUser,
})
req := authlib.CheckRequest{}
// Original should deny (no fallback)
err := original.Check(ctx, req, "")
require.Error(t, err, "original should deny without fallback")
// WithAdmin should deny for editor
err = withAdmin.Check(ctx, req, "")
require.Error(t, err, "admin fallback should deny for editor")
// WithEditor should allow for editor
err = withEditor.Check(ctx, req, "")
require.NoError(t, err, "editor fallback should allow for editor")
}
func TestSessionAccessChecker_WithFallbackRole_ChainedCalls(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: false},
}
// Ensure chained WithFallbackRole calls work correctly
checker := NewSessionAccessChecker(mock).
WithFallbackRole(identity.RoleAdmin).
WithFallbackRole(identity.RoleEditor) // This should override admin
ctx := identity.WithRequester(context.Background(), &mockRequester{
orgRole: identity.RoleEditor,
identityType: authlib.TypeUser,
})
err := checker.Check(ctx, authlib.CheckRequest{}, "")
require.NoError(t, err, "last fallback (editor) should be used")
}
func TestSessionAccessChecker_RealSignedInUser(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: false},
}
checker := NewSessionAccessChecker(mock).WithFallbackRole(identity.RoleAdmin)
// Use a real SignedInUser
signedInUser := &user.SignedInUser{
UserID: 1,
OrgID: 1,
OrgRole: identity.RoleAdmin,
}
ctx := identity.WithRequester(context.Background(), signedInUser)
err := checker.Check(ctx, authlib.CheckRequest{}, "")
require.NoError(t, err, "admin user should be allowed via fallback")
}
func TestSessionAccessChecker_FillsNamespace(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: true},
}
checker := NewSessionAccessChecker(mock)
ctx := identity.WithRequester(context.Background(), &mockRequester{
orgRole: identity.RoleAdmin,
identityType: authlib.TypeUser,
namespace: "org-123",
})
// Request without namespace
req := authlib.CheckRequest{
Verb: "get",
Group: "provisioning.grafana.app",
Resource: "repositories",
Name: "test-repo",
// Namespace intentionally empty
}
err := checker.Check(ctx, req, "")
require.NoError(t, err)
}
@@ -0,0 +1,92 @@
package auth
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
// tokenAccessChecker implements AccessChecker using access tokens from context.
type tokenAccessChecker struct {
inner authlib.AccessChecker
}
// NewTokenAccessChecker creates an AccessChecker that gets identity from access tokens
// via AuthInfoFrom(ctx). Role-based fallback is not supported.
func NewTokenAccessChecker(inner authlib.AccessChecker) AccessChecker {
return &tokenAccessChecker{inner: inner}
}
// WithFallbackRole returns the same checker since fallback is not supported.
func (c *tokenAccessChecker) WithFallbackRole(_ identity.RoleType) AccessChecker {
return c
}
// Check performs an access check using AuthInfo from context.
// Returns nil if access is allowed, or an appropriate API error if denied.
func (c *tokenAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error {
logger := logging.FromContext(ctx).With("logger", "tokenAccessChecker")
// Get identity from access token in context
id, ok := authlib.AuthInfoFrom(ctx)
if !ok {
logger.Debug("no auth info in context",
"resource", req.Resource,
"verb", req.Verb,
"namespace", req.Namespace,
)
return apierrors.NewUnauthorized("no auth info in context")
}
logger.Debug("checking access",
"identityType", id.GetIdentityType(),
"namespace", id.GetNamespace(),
"resource", req.Resource,
"verb", req.Verb,
"group", req.Group,
"name", req.Name,
"folder", folder,
)
// Fill in namespace from identity if not provided
if req.Namespace == "" {
req.Namespace = id.GetNamespace()
}
// Perform the access check
rsp, err := c.inner.Check(ctx, id, req, folder)
// Build the GroupResource for error messages
gr := schema.GroupResource{Group: req.Group, Resource: req.Resource}
if err != nil {
logger.Debug("access check error",
"resource", req.Resource,
"verb", req.Verb,
"error", err.Error(),
)
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
}
if !rsp.Allowed {
logger.Debug("access check denied",
"resource", req.Resource,
"verb", req.Verb,
"group", req.Group,
"identityType", id.GetIdentityType(),
"allowed", rsp.Allowed,
)
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied"))
}
logger.Debug("access allowed",
"resource", req.Resource,
"verb", req.Verb,
)
return nil
}
@@ -0,0 +1,137 @@
package auth
import (
"context"
"errors"
"testing"
apierrors "k8s.io/apimachinery/pkg/api/errors"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenAccessChecker_Check(t *testing.T) {
req := authlib.CheckRequest{
Verb: "get",
Group: "provisioning.grafana.app",
Resource: "repositories",
Name: "test-repo",
Namespace: "default",
}
tests := []struct {
name string
innerResponse authlib.CheckResponse
innerErr error
authInfo *identity.StaticRequester
expectAllow bool
}{
{
name: "allowed by checker",
innerResponse: authlib.CheckResponse{Allowed: true},
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
expectAllow: true,
},
{
name: "denied by checker",
innerResponse: authlib.CheckResponse{Allowed: false},
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
expectAllow: false,
},
{
name: "error from checker",
innerErr: errors.New("access check failed"),
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
expectAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockInnerAccessChecker{
response: tt.innerResponse,
err: tt.innerErr,
}
checker := NewTokenAccessChecker(mock)
// Add auth info to context
testCtx := authlib.WithAuthInfo(context.Background(), tt.authInfo)
err := checker.Check(testCtx, req, "")
if tt.expectAllow {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err)
}
})
}
}
func TestTokenAccessChecker_NoAuthInfo(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: true},
}
checker := NewTokenAccessChecker(mock)
err := checker.Check(context.Background(), authlib.CheckRequest{}, "")
require.Error(t, err)
assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error")
}
func TestTokenAccessChecker_WithFallbackRole_IsNoOp(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: false},
}
checker := NewTokenAccessChecker(mock)
checkerWithFallback := checker.WithFallbackRole(identity.RoleAdmin)
// They should be the same instance
assert.Same(t, checker, checkerWithFallback, "WithFallbackRole should return same instance")
}
func TestTokenAccessChecker_FillsNamespace(t *testing.T) {
mock := &mockInnerAccessChecker{
response: authlib.CheckResponse{Allowed: true},
}
checker := NewTokenAccessChecker(mock)
ctx := authlib.WithAuthInfo(context.Background(), &identity.StaticRequester{
Type: authlib.TypeUser,
Namespace: "org-123",
})
// Request without namespace
req := authlib.CheckRequest{
Verb: "get",
Group: "provisioning.grafana.app",
Resource: "repositories",
Name: "test-repo",
// Namespace intentionally empty
}
err := checker.Check(ctx, req, "")
require.NoError(t, err)
}
// mockInnerAccessChecker implements authlib.AccessChecker for testing.
type mockInnerAccessChecker struct {
response authlib.CheckResponse
err error
}
func (m *mockInnerAccessChecker) Check(_ context.Context, _ authlib.AuthInfo, _ authlib.CheckRequest, _ string) (authlib.CheckResponse, error) {
return m.response, m.err
}
func (m *mockInnerAccessChecker) Compile(_ context.Context, _ authlib.AuthInfo, _ authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) {
return nil, nil, nil
}
+7 -1
View File
@@ -23,7 +23,13 @@ func NewSimpleRepositoryTester(validator RepositoryValidator) SimpleRepositoryTe
// TestRepository validates the repository and then runs a health check
func (t *SimpleRepositoryTester) TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
errors := t.validator.ValidateRepository(repo)
// Determine if this is a CREATE or UPDATE operation
// If the repository has been observed by the controller (ObservedGeneration > 0),
// it's an existing repository and we should treat it as UPDATE
cfg := repo.Config()
isCreate := cfg.Status.ObservedGeneration == 0
errors := t.validator.ValidateRepository(repo, isCreate)
if len(errors) > 0 {
rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid
@@ -32,7 +32,9 @@ func NewValidator(minSyncInterval time.Duration, allowedTargets []provisioning.S
}
// ValidateRepository solely does configuration checks on the repository object. It does not run a health check or compare against existing repositories.
func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorList {
// isCreate indicates whether this is a CREATE operation (true) or UPDATE operation (false).
// When isCreate is false, allowedTargets validation is skipped to allow existing repositories to continue working.
func (v *RepositoryValidator) ValidateRepository(repo Repository, isCreate bool) field.ErrorList {
list := repo.Validate()
cfg := repo.Config()
@@ -44,7 +46,7 @@ func (v *RepositoryValidator) ValidateRepository(repo Repository) field.ErrorLis
if cfg.Spec.Sync.Target == "" {
list = append(list, field.Required(field.NewPath("spec", "sync", "target"),
"The target type is required when sync is enabled"))
} else if !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) {
} else if isCreate && !slices.Contains(v.allowedTargets, cfg.Spec.Sync.Target) {
list = append(list,
field.Invalid(
field.NewPath("spec", "target"),
@@ -303,7 +303,8 @@ func TestValidateRepository(t *testing.T) {
validator := NewValidator(10*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder}, false)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errors := validator.ValidateRepository(tt.repository)
// Tests validate new configurations, so always pass isCreate=true
errors := validator.ValidateRepository(tt.repository, true)
require.Len(t, errors, tt.expectedErrs)
if tt.validateError != nil {
tt.validateError(t, errors)
+4 -1
View File
@@ -335,6 +335,9 @@ rudderstack_data_plane_url =
# Rudderstack SDK url, optional, only valid if rudderstack_write_key and rudderstack_data_plane_url is also set
rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
rudderstack_config_url =
@@ -2261,7 +2264,7 @@ fail_tests_on_console = true
# List of targets that can be controlled by a repository, separated by |.
# Instance means the whole grafana instance will be controlled by a repository.
# Folder limits it to a folder within the grafana instance.
allowed_targets = instance|folder
allowed_targets = folder
# Whether image rendering is allowed for dashboard previews.
# Requires image rendering service to be configured.
+3
View File
@@ -322,6 +322,9 @@
# Rudderstack SDK url, optional, only valid if rudderstack_write_key and rudderstack_data_plane_url is also set
;rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
;rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
;rudderstack_config_url =
@@ -299,15 +299,9 @@
"y": 0
},
"id": 6,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "# Graph panel >> Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown"
},
"options": {},
"content": "# Graph panel >> Timeseries panel\n\nKnown issues:\n* hiding null/empty series\n* time regions",
"mode": "markdown",
"pluginVersion": "11.0.0-pre",
"targets": [
{
@@ -71,13 +71,12 @@
"id": 1,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -148,13 +147,12 @@
"id": 4,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -225,13 +223,12 @@
"id": 3,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -299,93 +296,15 @@
"x": 12,
"y": 1
},
"id": 5,
"maxDataPoints": 20,
"options": {
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"segmentCount": 1,
"segmentSpacing": 0.3,
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
{
"alias": "1",
"datasource": {
"type": "grafana-testdata-datasource"
},
"max": 100,
"min": 1,
"noise": 22,
"refId": "A",
"scenarioId": "random_walk",
"spread": 22,
"startValue": 1
}
],
"title": "Spotlight",
"type": "radialbar"
},
{
"datasource": {
"type": "grafana-testdata-datasource"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 6,
"w": 4,
"x": 16,
"y": 1
},
"id": 8,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -450,19 +369,18 @@
"gridPos": {
"h": 6,
"w": 4,
"x": 0,
"y": 7
"x": 16,
"y": 1
},
"id": 22,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -527,19 +445,18 @@
"gridPos": {
"h": 6,
"w": 4,
"x": 4,
"y": 7
"x": 20,
"y": 1
},
"id": 23,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -579,7 +496,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 13
"y": 7
},
"id": 17,
"panels": [],
@@ -616,20 +533,19 @@
},
"gridPos": {
"h": 6,
"w": 5,
"w": 4,
"x": 0,
"y": 14
"y": 8
},
"id": 18,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.1,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -693,20 +609,19 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 5,
"y": 14
"w": 4,
"x": 4,
"y": 8
},
"id": 19,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.32,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -770,20 +685,19 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 10,
"y": 14
"w": 4,
"x": 8,
"y": 8
},
"id": 20,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.57,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -847,20 +761,19 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 15,
"y": 14
"w": 4,
"x": 12,
"y": 8
},
"id": 21,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidthFactor": 0.8,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -900,7 +813,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 20
"y": 14
},
"id": 24,
"panels": [],
@@ -941,20 +854,19 @@
},
"gridPos": {
"h": 6,
"w": 6,
"w": 4,
"x": 0,
"y": 21
"y": 15
},
"id": 25,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1018,20 +930,19 @@
},
"gridPos": {
"h": 6,
"w": 6,
"x": 6,
"y": 21
"w": 4,
"x": 4,
"y": 15
},
"id": 26,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1095,20 +1006,19 @@
},
"gridPos": {
"h": 6,
"w": 5,
"x": 12,
"y": 21
"w": 4,
"x": 8,
"y": 15
},
"id": 29,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1171,21 +1081,20 @@
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 0,
"y": 27
"h": 6,
"w": 4,
"x": 12,
"y": 15
},
"id": 30,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1248,21 +1157,20 @@
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 6,
"y": 27
"h": 6,
"w": 4,
"x": 16,
"y": 15
},
"id": 28,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.72,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false
"gradient": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1298,7 +1206,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 34
"y": 21
},
"id": 31,
"panels": [],
@@ -1345,18 +1253,17 @@
"h": 10,
"w": 7,
"x": 0,
"y": 35
"y": 22
},
"id": 32,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1426,18 +1333,17 @@
"h": 10,
"w": 7,
"x": 7,
"y": 35
"y": 22
},
"id": 34,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1507,18 +1413,17 @@
"h": 10,
"w": 6,
"x": 14,
"y": 35
"y": 22
},
"id": 33,
"maxDataPoints": 20,
"options": {
"barShape": "flat",
"barWidthFactor": 0.9,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -1554,7 +1459,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 45
"y": 32
},
"id": 6,
"panels": [],
@@ -1595,20 +1500,20 @@
"h": 6,
"w": 24,
"x": 0,
"y": 46
"y": 33
},
"id": 9,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"endpointMarker": "glow",
"glow": "both",
"orientation": "auto",
"reduceOptions": {
@@ -1621,8 +1526,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1677,19 +1581,18 @@
"h": 6,
"w": 24,
"x": 0,
"y": 52
"y": 39
},
"id": 11,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.4,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1703,8 +1606,7 @@
"shape": "gauge",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": true,
"spotlight": true
"sparkline": true
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1731,7 +1633,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 58
"y": 45
},
"id": 12,
"panels": [],
@@ -1773,19 +1675,18 @@
"h": 7,
"w": 4,
"x": 0,
"y": 59
"y": 46
},
"id": 13,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1799,8 +1700,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1856,19 +1756,18 @@
"h": 7,
"w": 5,
"x": 4,
"y": 59
"y": 46
},
"id": 14,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.49,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1882,8 +1781,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -1938,19 +1836,18 @@
"h": 7,
"w": 5,
"x": 9,
"y": 59
"y": 46
},
"id": 15,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.84,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -1964,8 +1861,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2020,19 +1916,18 @@
"h": 7,
"w": 6,
"x": 14,
"y": 59
"y": 46
},
"id": 16,
"maxDataPoints": 20,
"options": {
"barShape": "rounded",
"barWidth": 12,
"barWidthFactor": 0.66,
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true
"gradient": true
},
"glow": "both",
"orientation": "auto",
@@ -2046,8 +1941,7 @@
"shape": "circle",
"showThresholdLabels": false,
"showThresholdMarkers": false,
"sparkline": false,
"spotlight": true
"sparkline": false
},
"pluginVersion": "13.0.0-pre",
"targets": [
@@ -2074,7 +1968,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 66
"y": 53
},
"id": 35,
"panels": [],
@@ -2105,20 +1999,19 @@
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"h": 5,
"w": 12,
"x": 0,
"y": 67
"y": 54
},
"id": 36,
"options": {
"barShape": "flat",
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -2171,20 +2064,19 @@
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 67
"h": 5,
"w": 12,
"x": 12,
"y": 54
},
"id": 37,
"options": {
"barShape": "flat",
"barWidthFactor": 0.5,
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false
"gradient": true
},
"orientation": "auto",
"reduceOptions": {
@@ -2224,5 +2116,6 @@
"timezone": "browser",
"title": "Panel tests - Gauge (new)",
"uid": "panel-tests-gauge-new",
"version": 9
"version": 22,
"weekStart": ""
}
@@ -956,8 +956,6 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"rounded": false,
"spotlight": false,
"gradient": false
}
}
@@ -128,35 +128,48 @@ The set up process verifies the path and provides an error message if a problem
#### Synchronization limitations
Full instance sync is not available in Grafana Cloud.
{{< admonition type="caution" >}}
In Grafana OSS/Enterprise:
Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise.
{{< /admonition >}}
To have access to full instance sync you must explicitly enable the option.
The following applies:
- If you try to perform a full instance sync with resources that contain alerts or panels, the connection will be blocked.
- You won't be able to create new alerts or library panels after setup is completed.
- If you opted for full instance sync and want to use alerts and library panels, you'll have to delete the provisioned repository and connect again with folder sync.
#### Set up synchronization
Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections).
You can sync external resources into a new folder without affecting the rest of your instance.
- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to.
To set up synchronization:
- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections.
1. Select which resources you want to sync.
Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
Click **Synchronize** to continue.
1. Click **Synchronize** to continue.
1. You can repeat this process for up to 10 connections.
{{< admonition type="note" >}}
Optionally, you can export any unmanaged resources into the provisioned folder. See how in [Synchronize with external storage](#synchronize-with-external-storage).
{{< /admonition >}}
### Synchronize with external storage
After this one time step, all future updates are automatically saved to the local file path and provisioned back to the instance.
In this step you proceed to synchronize the resources selected in the previous step. Optionally, you can check the **Migrate existing resources** box to migrate your unmanaged dashboards to the provisioned folder.
During the initial synchronization, your dashboards will be temporarily unavailable. No data or configurations will be lost.
Select **Begin synchronization** to start the process. After this one time step, all future updates are automatically saved to the local file path and provisioned back to the instance.
Note that during the initial synchronization, your dashboards will be temporarily unavailable. No data or configurations will be lost.
How long the process takes depends upon the number of resources involved.
Select **Begin synchronization** to start the process.
### Choose additional settings
If you wish, you can make any files synchronized as as **Read only** so no changes can be made to the resources through Grafana.
@@ -132,17 +132,35 @@ To connect your GitHub repository:
### Choose what to synchronize
In this step, you can decide which elements to synchronize. The available options depend on the status of your Grafana instance:
- If the instance contains resources in an incompatible data format, you'll have to migrate all the data using instance sync. Folder sync won't be supported.
- If there's already another connection using folder sync, instance sync won't be offered.
You can sync external resources into a new folder without affecting the rest of your instance.
To set up synchronization:
- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to.
- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections.
1. Select which resources you want to sync.
Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. Click **Synchronize** to continue.
1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
1. Click **Synchronize** to continue.
1. You can repeat this process for up to 10 connections.
{{< admonition type="note" >}}
Optionally, you can export any unmanaged resources into the provisioned folder. See how in [Synchronize with external storage](#synchronize-with-external-storage).
{{< /admonition >}}
#### Full instance sync
Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise.
To have access to this option you must enable experimental instance sync on purpose.
### Synchronize with external storage
After this one time step, all future updates are automatically saved to the Git repository and provisioned back to the instance.
Check the **Migrate existing resources** box to migrate your unmanaged dashboards to the provisioned folder.
### Choose additional settings
@@ -47,7 +47,7 @@ Using Git Sync, you can:
{{< admonition type="caution" >}}
Git Sync only works with specific folders for the moment. Full-instance sync is not currently supported.
Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise.
{{< /admonition >}}
@@ -84,7 +84,7 @@ Refer to [Requirements](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/obser
- You can only sync dashboards and folders. Refer to [Supported resources](#supported-resources) for more information.
- If you're using Git Sync in Grafana OSS and Grafana Enterprise, some resources might be in an incompatible data format and won't be synced.
- Full-instance sync is not available in Grafana Cloud and has limitations in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details.
- Full-instance sync is not available in Grafana Cloud and is experimental in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details.
- When migrating to full instance sync, during the synchronization process your resources will be temporarily unavailable. No one will be able to create, edit, or delete resources during this process.
- If you want to manage existing resources with Git Sync, you need to save them as JSON files and commit them to the synced repository. Open a PR to import, copy, move, or save a dashboard.
- Restoring resources from the UI is currently not possible. As an alternative, you can restore dashboards directly in your GitHub repository by raising a PR, and they will be updated in Grafana.
+6
View File
@@ -112,6 +112,12 @@ For example, this video demonstrates the visual Prometheus query builder:
For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data](ref:query-transform-data).
## Build a dashboard from the data source
After you've configured a data source, you can start creating a dashboard directly from it, by clicking the **Build a dashboard** button.
For more information, refer to [Begin dashboard creation from data source configuration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/create-dashboard/#begin-dashboard-creation-from-connections).
## Special data sources
Grafana includes three special data sources:
@@ -54,18 +54,6 @@ SCIM offers several advantages for managing users and teams in Grafana:
## Authentication and access requirements
{{< admonition type="warning" title="Critical: Aligning SAML Identifier with SCIM externalId" >}}
When using SAML for authentication alongside SCIM provisioning, a critical security measure is to ensure proper alignment between the the SCIM user's `externalId` and the SAML user identifier. The unique identifier used for SCIM provisioning (which becomes the `externalId` in Grafana, often sourced from a stable IdP attribute like Entra ID's `user.objectid`) **must also be sent as a claim in the SAML assertion from your Identity Provider.**
Furthermore, the Grafana SAML configuration must be correctly set up to identify and use this specific claim for linking the authenticated SAML user to their SCIM-provisioned user. This can be achieved by either ensuring the primary SAML login identifier by using the `assertion_attribute_external_uid` setting in Grafana to explicitly set the name of the SAML claim that contains the stable unique identifier attribute.
**Why is this important?**
A mismatch or inconsistent mapping between this SAML login identifier and the SCIM `externalId` creates a critical security vulnerability. If these two identifiers are not reliably and uniquely aligned for each individual user, Grafana may fail to correctly link an authenticated SAML session to the intended SCIM-provisioned user profile and its associated permissions. This can enable a malicious actor to impersonate another user—for instance, by crafting a SAML assertion that, due to the identifier misalignment, incorrectly grants them the access rights of the targeted user.
Grafana relies on this linkage to correctly associate the authenticated user from SAML with the provisioned user from SCIM. Failure to ensure a consistent and unique identifier across both systems can break this linkage, leading to incorrect user mapping and potential unauthorized access.
Always verify that your SAML identity provider is configured to send a stable, unique user identifier that your SCIM configuration maps to `externalId`. Refer to your identity provider's documentation and the specific Grafana SCIM integration guides (e.g., for [Entra ID](configure-scim-with-azuread/) or [Okta](configure-scim-with-okta/)) for detailed instructions on configuring these attributes correctly.
{{< /admonition >}}
When you enable SCIM in Grafana, the following requirements and restrictions apply:
1. **Use the same identity provider for user provisioning and for authentication flow**: You must use the same identity provider for both authentication and user provisioning.
@@ -74,6 +62,12 @@ When you enable SCIM in Grafana, the following requirements and restrictions app
- Configure `userUID` SAML assertion in [Entra ID](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/saml/configure-saml-with-azuread/#configure-saml-assertions-when-using-scim-provisioning)
- Configure `userUID` SAML assertion in [Okta](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/saml/configure-saml-with-okta/#configure-saml-assertions-when-using-scim-provisioning)
### Align SAML identifier with SCIM `externalId`
When you use SAML with SCIM provisioning, align the SCIM `externalId` with the SAML user identifier. Use a stable IdP attribute (for example, Entra ID `user.objectid`) as the SCIM `externalId`, and send that same value as a SAML claim. Configure Grafana to read this claim with the `assertion_attribute_external_uid` setting so SAML authentication links to the SCIM-provisioned user and its permissions.
If the SAML identifier and SCIM `externalId` differ, Grafana may not link the authenticated user to the intended SCIM profile, which can result in incorrect access. Verify your IdP sends a stable, unique identifier and that it matches the SCIM `externalId`. Refer to your IdP docs and the Grafana SCIM integration guides for [Entra ID](configure-scim-with-azuread/) and [Okta](configure-scim-with-okta/) for attribute configuration details.
## Configure SCIM using the Grafana user interface
You can configure SCIM in Grafana using the Grafana user interface. To do this, navigate to **Administration > Authentication > SCIM**.
@@ -642,6 +642,12 @@ You must also provide the `rudderstack_write_key` to enable this feature.
Optional.
If tracking with RudderStack is enabled, you can provide a custom URL to load the RudderStack SDK.
#### `rudderstack_v3_sdk_url`
Optional.
This is mirroring the old configuration option, which will be deprecated.
If `rudderstack_sdk_url` and `rudderstack_v3_sdk_url` are both set, the feature toggle `rudderstackUpgrade` will control which one is loaded.
#### `rudderstack_config_url`
Optional.
@@ -31,7 +31,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | Yes |
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
@@ -163,9 +163,9 @@ To add a new annotation query to a dashboard, follow these steps:
1. To create a query, do one of the following:
- Write or construct a query in the query language of your data source. The annotation query options are different for each data source. For information about annotations in a specific data source, refer to the specific [data source](ref:data-source) topic.
- Click **Replace with saved query** to reuse a [saved query](ref:saved-queries).
- Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](ref:saved-queries).
1. (Optional) To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon).
1. (Optional) To [save the query](ref:save-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option.
1. (Optional) Click **Test annotation query** to ensure that the query is working properly.
1. (Optional) To add subsequent queries, click **+ Add query** or **+ Add from saved queries**, and test them as many times as needed.
@@ -99,7 +99,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee
- Understand the query language of the target data source.
- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions.
**To create a dashboard**:
To create a dashboard, follow these steps:
{{< shared id="create-dashboard" >}}
@@ -125,9 +125,9 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee
1. To create a query, do one of the following:
- Write or construct a query in the query language of your data source.
- Click **Replace with saved query** to reuse a [saved query](ref:saved-queries).
- Open the **Saved queries** drop-down menu and click **Replace query** to reuse a [saved query](ref:saved-queries).
1. (Optional) To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon).
1. (Optional) To [save the query](ref:save-query) for reuse, open the **Saved queries** drop-down menu and click the **Save query** option.
1. Click **Refresh** to query the data source.
1. (Optional) To add subsequent queries, click **+ Add query** or **+ Add from saved queries**, and refresh the data source as many times as needed.
@@ -171,6 +171,28 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee
Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner.
### Begin dashboard creation from data source configuration
You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page.
To begin building a dashboard directly from a data source, follow these steps:
1. Navigate to **Connections > Data sources**.
1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**.
The empty dashboard page opens.
1. Do one of the following:
- Click **+Add visualization** to configure all the elements of the new dashboard.
- Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data.
The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them.
![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png)
{{< docs/public-preview product="Suggested dashboards" >}}
1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five.
## Copy a dashboard
To copy a dashboard, follow these steps:
@@ -71,8 +71,9 @@ Explore consists of a toolbar, outline, query editor, the ability to add multipl
- **Run query** - Click to run your query.
- **Query editor** - Interface where you construct the query for a specific data source. Query editor elements differ based on data source. In order to run queries across multiple data sources you need to select **Mixed** from the data source picker.
- **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon).
- **Replace with saved query** - Reuse a saved query.
- **Saved queries**:
- **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon).
- **Replace query** - Reuse a saved query.
- **+ Add query** - Add an additional query.
- **+ Add from saved queries** - Add an additional query by reusing a saved query.
@@ -88,8 +88,9 @@ The data section contains tabs where you enter queries, transform your data, and
- **Queries**
- Select your data source. You can also set or update the data source in existing dashboards using the drop-down menu in the **Queries** tab.
- **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon).
- **Replace with saved query** - Reuse a saved query.
- **Saved queries**:
- **Save query** - To [save the query](ref:save-query) for reuse, click the **Save query** button (or icon).
- **Replace query** - Reuse a saved query.
- **+ Add query** - Add an additional query.
- **+ Add from saved queries** - Add an additional query by reusing a saved query.
@@ -156,11 +156,11 @@ In the **Saved queries** drawer, you can:
- Edit a query title, description, tags, or the availability of the query to other users in your organization. By default, saved queries are locked for editing.
- When you access the **Saved queries** drawer from Explore, you can use the **Edit in Explore** option to edit the body of a query.
To access your saved queries, click **+ Add from saved queries** or **Replace with saved query** in the query editor:
To access your saved queries, click **+ Add from saved queries** or open the **Saved queries** drop-down menu and click **Replace query** in the query editor:
{{< figure src="/media/docs/grafana/dashboards/screenshot-use-saved-queries-v12.3.png" max-width="750px" alt="Access saved queries" >}}
Clicking **+ Add from saved queries** adds an additional query, while clicking **Replace with saved query** updates your existing query.
Clicking **+ Add from saved queries** adds an additional query, while clicking **Replace query** in the **Saved queries** drop-down menu updates your existing query.
{{< admonition type="note" >}}
Users with Admin and Editor roles can create and save queries for reuse.
@@ -172,7 +172,7 @@ Viewers can only reuse queries.
To save a query you've created:
1. From the query editor, click the **Save query** icon:
1. From the query editor, open the **Saved queries** drop-down menu and click the **Save query** option:
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-save-query-v12.2.png" max-width="750px" alt="Save a query" >}}
@@ -227,7 +227,7 @@ To add a query, follow these steps:
1. To create a query, do one of the following:
- Write or construct a query in the query language of your data source.
- Click **Replace with saved query** to reuse a saved query.
- Open the **Saved queries** drop-down menu and click **Replace query** to reuse a saved query.
{{< admonition type="note" >}}
[Saved queries](#saved-queries) is currently in [public preview](https://grafana.com/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available.
@@ -235,7 +235,7 @@ To add a query, follow these steps:
This feature is only available on Grafana Enterprise and Grafana Cloud.
{{< /admonition >}}
1. (Optional) To [save the query](#save-a-query) for reuse, click the **Save query** button (or icon).
1. (Optional) To [save the query](#save-a-query) for reuse, click the **Save query** option in the **Saved queries** drop-down menu.
1. (Optional) Click **+ Add query** or **Add from saved queries** to add more queries as needed.
1. Click **Run queries**.
@@ -0,0 +1,271 @@
import { Page } from '@playwright/test';
import { test, expect } from '@grafana/plugin-e2e';
/**
* UI selectors for Saved Searches e2e tests.
* Each selector is a function that takes the page and returns a locator.
*/
const ui = {
// Main elements
savedSearchesButton: (page: Page) => page.getByRole('button', { name: /saved searches/i }),
dropdown: (page: Page) => page.getByRole('dialog', { name: /saved searches/i }),
searchInput: (page: Page) => page.getByTestId('search-query-input'),
// Save functionality
saveButton: (page: Page) => page.getByRole('button', { name: /save current search/i }),
saveConfirmButton: (page: Page) => page.getByRole('button', { name: /^save$/i }),
saveNameInput: (page: Page) => page.getByPlaceholder(/enter a name/i),
// Action menu
actionsButton: (page: Page) => page.getByRole('button', { name: /actions/i }),
renameMenuItem: (page: Page) => page.getByText(/rename/i),
deleteMenuItem: (page: Page) => page.getByText(/^delete$/i),
setAsDefaultMenuItem: (page: Page) => page.getByText(/set as default/i),
deleteConfirmButton: (page: Page) => page.getByRole('button', { name: /^delete$/i }),
// Indicators
emptyState: (page: Page) => page.getByText(/no saved searches/i),
defaultIcon: (page: Page) => page.locator('[title="Default search"]'),
duplicateError: (page: Page) => page.getByText(/already exists/i),
};
/**
* Helper to clear saved searches storage.
* UserStorage uses localStorage as fallback, so we clear both potential keys.
*/
async function clearSavedSearches(page: Page) {
await page.evaluate(() => {
// Clear localStorage keys that might contain saved searches
// UserStorage stores under 'grafana.userstorage.alerting' pattern
const keysToRemove = Object.keys(localStorage).filter(
(key) => key.includes('alerting') && (key.includes('savedSearches') || key.includes('userstorage'))
);
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Also clear session storage visited flag
const sessionKeysToRemove = Object.keys(sessionStorage).filter((key) => key.includes('alerting'));
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
});
}
test.describe(
'Alert Rules - Saved Searches',
{
tag: ['@alerting'],
},
() => {
test.beforeEach(async ({ page }) => {
// Clear any saved searches from previous tests before navigating
await page.goto('/alerting/list');
await clearSavedSearches(page);
await page.reload();
});
test.afterEach(async ({ page }) => {
// Clean up saved searches after each test
await clearSavedSearches(page);
});
test('should display Saved searches button', async ({ page }) => {
await expect(ui.savedSearchesButton(page)).toBeVisible();
});
test('should open dropdown when clicking Saved searches button', async ({ page }) => {
await ui.savedSearchesButton(page).click();
await expect(ui.dropdown(page)).toBeVisible();
});
test('should show empty state when no saved searches exist', async ({ page }) => {
// Storage is cleared in beforeEach, so we should see empty state
await ui.savedSearchesButton(page).click();
await expect(ui.emptyState(page)).toBeVisible();
});
test('should enable Save current search button when search query is entered', async ({ page }) => {
// Enter a search query
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
// Open saved searches
await ui.savedSearchesButton(page).click();
await expect(ui.saveButton(page)).toBeEnabled();
});
test('should disable Save current search button when search query is empty', async ({ page }) => {
await ui.savedSearchesButton(page).click();
await expect(ui.saveButton(page)).toBeDisabled();
});
test('should save a new search', async ({ page }) => {
// Enter a search query
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
// Open saved searches
await ui.savedSearchesButton(page).click();
// Click save button
await ui.saveButton(page).click();
// Enter name and save
await ui.saveNameInput(page).fill('My Firing Rules');
await ui.saveConfirmButton(page).click();
// Verify the saved search appears in the list
await expect(page.getByText('My Firing Rules')).toBeVisible();
});
test('should show validation error for duplicate name', async ({ page }) => {
// First save a search
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
await ui.savedSearchesButton(page).click();
await ui.saveButton(page).click();
await ui.saveNameInput(page).fill('Duplicate Test');
await ui.saveConfirmButton(page).click();
// Try to save another with the same name
await ui.saveButton(page).click();
await ui.saveNameInput(page).fill('Duplicate Test');
await ui.saveConfirmButton(page).click();
// Verify validation error
await expect(ui.duplicateError(page)).toBeVisible();
});
test('should apply a saved search', async ({ page }) => {
// Create a saved search first
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
await ui.savedSearchesButton(page).click();
await ui.saveButton(page).click();
await ui.saveNameInput(page).fill('Apply Test');
await ui.saveConfirmButton(page).click();
// Clear the search
await ui.searchInput(page).clear();
await ui.searchInput(page).press('Enter');
// Apply the saved search
await ui.savedSearchesButton(page).click();
await page.getByRole('button', { name: /apply search.*apply test/i }).click();
// Verify the search input is updated
await expect(ui.searchInput(page)).toHaveValue('state:firing');
});
test('should rename a saved search', async ({ page }) => {
// Create a saved search
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
await ui.savedSearchesButton(page).click();
await ui.saveButton(page).click();
await ui.saveNameInput(page).fill('Original Name');
await ui.saveConfirmButton(page).click();
// Open action menu and click rename
await ui.actionsButton(page).click();
await ui.renameMenuItem(page).click();
// Enter new name
const renameInput = page.getByDisplayValue('Original Name');
await renameInput.clear();
await renameInput.fill('Renamed Search');
await page.keyboard.press('Enter');
// Verify the name was updated
await expect(page.getByText('Renamed Search')).toBeVisible();
await expect(page.getByText('Original Name')).not.toBeVisible();
});
test('should delete a saved search', async ({ page }) => {
// Create a saved search
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
await ui.savedSearchesButton(page).click();
await ui.saveButton(page).click();
await ui.saveNameInput(page).fill('To Delete');
await ui.saveConfirmButton(page).click();
// Verify it was saved
await expect(page.getByText('To Delete')).toBeVisible();
// Open action menu and click delete
await ui.actionsButton(page).click();
await ui.deleteMenuItem(page).click();
// Confirm delete
await ui.deleteConfirmButton(page).click();
// Verify it was deleted
await expect(page.getByText('To Delete')).not.toBeVisible();
});
test('should set a search as default', async ({ page }) => {
// Create a saved search
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
await ui.savedSearchesButton(page).click();
await ui.saveButton(page).click();
await ui.saveNameInput(page).fill('Default Test');
await ui.saveConfirmButton(page).click();
// Set as default
await ui.actionsButton(page).click();
await ui.setAsDefaultMenuItem(page).click();
// Verify the star icon appears (indicating default)
await expect(ui.defaultIcon(page)).toBeVisible();
});
test('should close dropdown when pressing Escape', async ({ page }) => {
await ui.savedSearchesButton(page).click();
await expect(ui.dropdown(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(ui.dropdown(page)).not.toBeVisible();
});
test('should cancel save mode when pressing Escape', async ({ page }) => {
// Enter a search query
await ui.searchInput(page).fill('state:firing');
await ui.searchInput(page).press('Enter');
await ui.savedSearchesButton(page).click();
// Start save mode
await ui.saveButton(page).click();
await expect(ui.saveNameInput(page)).toBeVisible();
// Press Escape to cancel
await page.keyboard.press('Escape');
// Verify we're back to list mode
await expect(ui.saveNameInput(page)).not.toBeVisible();
await expect(ui.saveButton(page)).toBeVisible();
});
}
);
+1 -11
View File
@@ -1888,11 +1888,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/serialization/angularMigration.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"public/app/features/dashboard-scene/serialization/buildNewDashboardSaveModel.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -1916,11 +1911,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/settings/JsonModelEditView.tsx": {
"react/no-unescaped-entities": {
"count": 2
}
},
"public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": {
"react-hooks/rules-of-hooks": {
"count": 4
@@ -4339,7 +4329,7 @@
},
"public/app/plugins/panel/heatmap/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 16
"count": 14
}
},
"public/app/plugins/panel/histogram/Histogram.tsx": {
+2 -2
View File
@@ -295,8 +295,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "^6.51.0",
"@grafana/scenes-react": "^6.51.0",
"@grafana/scenes": "6.52.0",
"@grafana/scenes-react": "6.52.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -289,6 +289,7 @@ export interface GrafanaConfig {
rudderstackWriteKey: string;
rudderstackDataPlaneUrl: string;
rudderstackSdkUrl: string;
rudderstackV3SdkUrl: string;
rudderstackConfigUrl: string;
rudderstackIntegrationsUrl: string;
applicationInsightsConnectionString: string;
+13 -5
View File
@@ -126,11 +126,6 @@ export interface FeatureToggles {
*/
disableSSEDataplane?: boolean;
/**
* Writes error logs to the request logger
* @default true
*/
unifiedRequestLog?: boolean;
/**
* Uses JWT-based auth for rendering instead of relying on remote cache
*/
renderAuthJWT?: boolean;
@@ -499,6 +494,10 @@ export interface FeatureToggles {
*/
newDashboardWithFiltersAndGroupBy?: boolean;
/**
* Wraps the ad hoc and group by variables in a single wrapper, with all other variables below it
*/
dashboardAdHocAndGroupByWrapper?: boolean;
/**
* Updates CloudWatch label parsing to be more accurate
* @default true
*/
@@ -536,6 +535,10 @@ export interface FeatureToggles {
*/
alertingListViewV2?: boolean;
/**
* Enables saved searches for alert rules list
*/
alertingSavedSearches?: boolean;
/**
* Disables the ability to send alerts to an external Alertmanager datasource.
*/
alertingDisableSendAlertsExternal?: boolean;
@@ -1165,6 +1168,11 @@ export interface FeatureToggles {
*/
externalVizSuggestions?: boolean;
/**
* Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)
* @default false
*/
heatmapRowsAxisOptions?: boolean;
/**
* Restrict PanelChrome contents with overflow: hidden;
* @default true
*/
+1
View File
@@ -224,6 +224,7 @@ export class GrafanaBootConfig {
rudderstackWriteKey?: string;
rudderstackDataPlaneUrl?: string;
rudderstackSdkUrl?: string;
rudderstackV3SdkUrl?: string;
rudderstackConfigUrl?: string;
rudderstackIntegrationsUrl?: string;
analyticsConsoleReporting = false;
@@ -185,6 +185,10 @@ export interface RowsHeatmapOptions {
* Sets the name of the cell when not calculating from data
*/
value?: string;
/**
* Controls the scale distribution of the y-axis buckets
*/
yBucketScale?: ui.ScaleDistributionConfig;
}
export interface Options {
@@ -16,21 +16,19 @@ export interface GaugePanelEffects {
barGlow?: boolean;
centerGlow?: boolean;
gradient?: boolean;
rounded?: boolean;
spotlight?: boolean;
}
export const defaultGaugePanelEffects: Partial<GaugePanelEffects> = {
barGlow: false,
centerGlow: false,
gradient: true,
rounded: false,
spotlight: false,
};
export interface Options extends common.SingleStatBaseOptions {
barShape: ('flat' | 'rounded');
barWidthFactor: number;
effects: GaugePanelEffects;
endpointMarker?: ('point' | 'glow' | 'none');
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
@@ -40,8 +38,10 @@ export interface Options extends common.SingleStatBaseOptions {
}
export const defaultOptions: Partial<Options> = {
barShape: 'flat',
barWidthFactor: 0.5,
effects: {},
endpointMarker: 'point',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',
@@ -1,52 +1,149 @@
import { GaugeDimensions, toRad } from './utils';
import { useId, memo, HTMLAttributes, ReactNode } from 'react';
export interface RadialArcPathProps {
startAngle: number;
dimensions: GaugeDimensions;
color: string;
glowFilter?: string;
import { FieldDisplay } from '@grafana/data';
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import { drawRadialArcPath, toRad } from './utils';
export interface RadialArcPathPropsBase {
arcLengthDeg: number;
barEndcaps?: boolean;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
roundedBars?: boolean;
shape: RadialShape;
endpointMarker?: 'point' | 'glow';
startAngle: number;
glowFilter?: string;
endpointMarkerGlowFilter?: string;
}
export function RadialArcPath({
startAngle: angle,
dimensions,
color,
glowFilter,
arcLengthDeg,
roundedBars,
}: RadialArcPathProps) {
const { radius, centerX, centerY, barWidth } = dimensions;
interface RadialArcPathPropsWithColor extends RadialArcPathPropsBase {
color: string;
}
if (arcLengthDeg === 360) {
// For some reason a 100% full arc cannot be rendered
arcLengthDeg = 359.99;
interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
gradient: GradientStop[];
}
type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient;
const ENDPOINT_MARKER_MIN_ANGLE = 10;
const DOT_OPACITY = 0.5;
const DOT_RADIUS_FACTOR = 0.4;
const MAX_DOT_RADIUS = 8;
export const RadialArcPath = memo(
({
arcLengthDeg,
dimensions,
fieldDisplay,
roundedBars,
shape,
endpointMarker,
barEndcaps,
startAngle: angle,
glowFilter,
endpointMarkerGlowFilter,
...rest
}: RadialArcPathProps) => {
const id = useId();
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
if ('color' in rest) {
bgDivStyle.backgroundColor = rest.color;
} else {
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
}
const { radius, centerX, centerY, barWidth } = dimensions;
const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars);
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
const xStart = centerX + radius * Math.cos(startRadians);
const yStart = centerY + radius * Math.sin(startRadians);
const xEnd = centerX + radius * Math.cos(endRadians);
const yEnd = centerY + radius * Math.sin(endRadians);
const dotRadius =
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
let barEndcapColors: [string, string] | undefined;
let endpointMarks: ReactNode = null;
if ('gradient' in rest) {
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
switch (endpointMarker) {
case 'point':
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(
rest.gradient!,
fieldDisplay.display.percent
);
endpointMarks = (
<>
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
)}
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
</>
);
break;
case 'glow':
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
endpointMarks =
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
<path
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
fill="none"
strokeWidth={barWidth}
stroke={endpointMarkerGlowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
) : null;
break;
default:
break;
}
}
if (barEndcaps) {
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
}
}
return (
<>
{/* FIXME: optimize this by only using clippath + foreign obj for gradients */}
<clipPath id={id}>
<path d={path} />
</clipPath>
<g filter={glowFilter}>
<foreignObject
x={centerX - radius - barWidth}
y={centerY - radius - barWidth}
width={(radius + barWidth) * 2}
height={(radius + barWidth) * 2}
clipPath={`url(#${id})`}
>
<div style={bgDivStyle} />
</foreignObject>
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
{barEndcapColors?.[1] && (
<circle cx={xEnd} cy={yEnd} r={barWidth / 2} fill={barEndcapColors[1]} opacity={0.5} />
)}
</g>
{endpointMarks}
</>
);
}
);
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
let x1 = centerX + radius * Math.cos(startRadians);
let y1 = centerY + radius * Math.sin(startRadians);
let x2 = centerX + radius * Math.cos(endRadians);
let y2 = centerY + radius * Math.sin(endRadians);
const largeArc = arcLengthDeg > 180 ? 1 : 0;
const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' ');
return (
<path
d={path}
fill="none"
fillOpacity="1"
stroke={color}
strokeOpacity="1"
strokeWidth={barWidth}
filter={glowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
className="radial-arc-path"
/>
);
}
RadialArcPath.displayName = 'RadialArcPath';
@@ -1,97 +1,64 @@
import { GrafanaTheme2 } from '@grafana/data';
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions, toRad } from './utils';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
export interface RadialBarProps {
dimensions: GaugeDimensions;
colorDefs: RadialColorDefs;
angleRange: number;
angle: number;
startAngle: number;
angleRange: number;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
gradient?: GradientStop[];
roundedBars?: boolean;
spotlightStroke: string;
endpointMarker?: 'point' | 'glow';
shape: RadialShape;
startAngle: number;
glowFilter?: string;
endpointMarkerGlowFilter?: string;
}
export function RadialBar({
dimensions,
colorDefs,
angleRange,
angle,
startAngle,
angleRange,
dimensions,
fieldDisplay,
gradient,
roundedBars,
spotlightStroke,
endpointMarker,
shape,
startAngle,
glowFilter,
endpointMarkerGlowFilter,
}: RadialBarProps) {
const theme = useTheme2();
const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR };
return (
<>
<g>
{/** Track */}
<RadialArcPath
startAngle={startAngle + angle}
dimensions={dimensions}
arcLengthDeg={angleRange - angle}
color={theme.colors.action.hover}
roundedBars={roundedBars}
/>
{/** The colored bar */}
<RadialArcPath
dimensions={dimensions}
startAngle={startAngle}
arcLengthDeg={angle}
color={colorDefs.getMainBarColor()}
roundedBars={roundedBars}
glowFilter={glowFilter}
/>
{spotlightStroke && angle > 8 && (
<SpotlightSquareEffect
dimensions={dimensions}
angle={startAngle + angle}
glowFilter={glowFilter}
spotlightStroke={spotlightStroke}
theme={theme}
roundedBars={roundedBars}
/>
)}
</g>
<defs>{colorDefs.getDefs()}</defs>
{/** Track */}
<RadialArcPath
arcLengthDeg={angleRange - angle}
fieldDisplay={fieldDisplay}
color={theme.colors.action.hover}
dimensions={dimensions}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle + angle}
/>
{/** The colored bar */}
<RadialArcPath
arcLengthDeg={angle}
barEndcaps={shape === 'circle' && roundedBars}
dimensions={dimensions}
endpointMarker={roundedBars ? endpointMarker : undefined}
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle}
{...colorProps}
/>
</>
);
}
interface SpotlightEffectProps {
dimensions: GaugeDimensions;
angle: number;
glowFilter?: string;
spotlightStroke: string;
theme: GrafanaTheme2;
roundedBars?: boolean;
}
function SpotlightSquareEffect({ dimensions, angle, glowFilter, spotlightStroke, roundedBars }: SpotlightEffectProps) {
const { radius, centerX, centerY, barWidth } = dimensions;
const angleRadian = toRad(angle);
const x1 = centerX + radius * Math.cos(angleRadian - 0.2);
const y1 = centerY + radius * Math.sin(angleRadian - 0.2);
const x2 = centerX + radius * Math.cos(angleRadian);
const y2 = centerY + radius * Math.sin(angleRadian);
const path = ['M', x1, y1, 'A', radius, radius, 0, 0, 1, x2, y2].join(' ');
return (
<path
d={path}
fill="none"
strokeWidth={barWidth}
stroke={spotlightStroke}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
);
}
@@ -1,126 +1,74 @@
import { FieldDisplay } from '@grafana/data';
import { memo } from 'react';
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions } from './utils';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import {
getAngleBetweenSegments,
getFieldConfigMinMax,
getFieldDisplayProcessor,
getOptimalSegmentCount,
} from './utils';
export interface RadialBarSegmentedProps {
fieldDisplay: FieldDisplay;
dimensions: GaugeDimensions;
colorDefs: RadialColorDefs;
dimensions: RadialGaugeDimensions;
angleRange: number;
startAngle: number;
glowFilter?: string;
segmentCount: number;
segmentSpacing: number;
shape: RadialShape;
gradient?: GradientStop[];
}
export function RadialBarSegmented({
fieldDisplay,
dimensions,
startAngle,
angleRange,
glowFilter,
segmentCount,
segmentSpacing,
colorDefs,
}: RadialBarSegmentedProps) {
const segments: React.ReactNode[] = [];
const theme = useTheme2();
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
const min = fieldDisplay.field.min ?? 0;
const max = fieldDisplay.field.max ?? 100;
const value = fieldDisplay.display.numeric;
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
export const RadialBarSegmented = memo(
({
fieldDisplay,
dimensions,
startAngle,
angleRange,
glowFilter,
gradient,
segmentCount,
segmentSpacing,
shape,
}: RadialBarSegmentedProps) => {
const theme = useTheme2();
const segments: React.ReactNode[] = [];
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
const [min, max] = getFieldConfigMinMax(fieldDisplay);
const value = fieldDisplay.display.numeric;
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
const displayProcessor = getFieldDisplayProcessor(fieldDisplay);
for (let i = 0; i < segmentCountAdjusted; i++) {
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
const angleColor = colorDefs.getSegmentColor(angleValue);
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor;
for (let i = 0; i < segmentCountAdjusted; i++) {
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
const segmentColor =
angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR);
const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor };
segments.push(
<RadialArcPath
key={i}
startAngle={segmentAngle}
dimensions={dimensions}
color={segmentColor}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
/>
);
segments.push(
<RadialArcPath
key={i}
arcLengthDeg={segmentArcLengthDeg}
dimensions={dimensions}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
shape={shape}
startAngle={segmentAngle}
{...colorProps}
/>
);
}
return <g>{segments}</g>;
}
);
return (
<>
<g>{segments}</g>
<defs>{colorDefs.getDefs()}</defs>
</>
);
}
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
// Max spacing is 8 degrees between segments
// Changing this constant could be considered a breaking change
const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2);
return segmentSpacing * maxAngleBetweenSegments;
}
function getOptimalSegmentCount(
dimensions: GaugeDimensions,
segmentSpacing: number,
segmentCount: number,
range: number
) {
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range);
const innerRadius = dimensions.radius - dimensions.barWidth / 2;
const circumference = Math.PI * innerRadius * 2 * (range / 360);
const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3));
return Math.min(maxSegments, segmentCount);
}
// export function RadialSegmentLine({
// gaugeId,
// center,
// angle,
// size,
// color,
// barWidth,
// roundedBars,
// glow,
// margin,
// segmentWidth,
// }: RadialSegmentProps) {
// const arcSize = size - barWidth;
// const radius = arcSize / 2 - margin;
// const angleRad = (Math.PI * (angle - 90)) / 180;
// const lineLength = radius - barWidth;
// const x1 = center + radius * Math.cos(angleRad);
// const y1 = center + radius * Math.sin(angleRad);
// const x2 = center + lineLength * Math.cos(angleRad);
// const y2 = center + lineLength * Math.sin(angleRad);
// return (
// <line
// x1={x1}
// y1={y1}
// x2={x2}
// y2={y2}
// fill="none"
// fillOpacity="0.85"
// stroke={color}
// strokeOpacity="1"
// strokeLinecap={roundedBars ? 'round' : 'butt'}
// strokeWidth={segmentWidth}
// strokeDasharray="0"
// filter={glow ? `url(#glow-${gaugeId})` : undefined}
// />
// );
// }
RadialBarSegmented.displayName = 'RadialBarSegmented';
@@ -1,141 +0,0 @@
import tinycolor from 'tinycolor2';
import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
export interface RadialColorDefsOptions {
gradient: RadialGradientMode;
fieldDisplay: FieldDisplay;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
shape: RadialShape;
gaugeId: string;
displayProcessor: DisplayProcessor;
}
export class RadialColorDefs {
private colorToIds: Record<string, string> = {};
private defs: React.ReactNode[] = [];
constructor(private options: RadialColorDefsOptions) {}
getSegmentColor(forValue: number): string {
const { displayProcessor } = this.options;
const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR;
return this.getColor(baseColor, true);
}
getColor(baseColor: string, forSegment?: boolean): string {
const { gradient, dimensions, gaugeId, fieldDisplay, shape, theme } = this.options;
const id = `value-color-${baseColor}-${gaugeId}`;
if (this.colorToIds[id]) {
return this.colorToIds[id];
}
// If no gradient, just return the base color
if (gradient === 'none') {
this.colorToIds[id] = baseColor;
return baseColor;
}
const returnColor = (this.colorToIds[id] = `url(#${id})`);
const colorModeId = fieldDisplay.field.color?.mode;
const colorMode = getFieldColorMode(colorModeId);
const valuePercent = fieldDisplay.display.percent ?? 0;
// Handle continusous color modes first
// If it's a segment color we don't want to do continuous gradients
if (colorMode.isContinuous && colorMode.getColors && !forSegment) {
const colors = colorMode.getColors(theme);
const count = colors.length;
this.defs.push(
<linearGradient x1="0" y1="0" x2={1 / valuePercent} y2="0" id={id}>
{colors.map((stopColor, i) => (
<stop key={i} offset={`${(i / (count - 1)).toFixed(2)}`} stopColor={stopColor} stopOpacity={1} />
))}
</linearGradient>
);
return returnColor;
}
// For value based colors we want to stay more true to the specific color
// So a radial gradient that adds a bit of light and shade works best
if (colorMode.isByValue) {
const color1 = tinycolor(baseColor).darken(5);
this.defs.push(
<radialGradient
key={id}
id={id}
cx={dimensions.centerX}
cy={dimensions.centerY}
r={dimensions.radius + dimensions.barWidth / 2}
fr={dimensions.radius - dimensions.barWidth / 2}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor={tinycolor(baseColor).spin(20).lighten(10).toString()} stopOpacity={1} />
<stop offset="60%" stopColor={color1.toString()} stopOpacity={1} />
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
</radialGradient>
);
return returnColor;
}
// For fixed / palette based color scales we can create a more fun
// hue and light based linear gradient that we rotate/move with the value
const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius;
const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0;
const color1 = tinycolor(baseColor).spin(-20).darken(5);
const color2 = tinycolor(baseColor).saturate(20).spin(20).brighten(10);
// this makes it so the gradient is always brightest at the current value
const transform =
shape === 'circle'
? `rotate(${360 * valuePercent - 180} ${dimensions.centerX} ${dimensions.centerY})`
: `translate(-${dimensions.radius * 2 * (1 - valuePercent)}, 0)`;
this.defs.push(
<linearGradient
key={id}
id={id}
x1="0"
y1="0"
x2={x2}
y2={y2}
gradientUnits="userSpaceOnUse"
gradientTransform={transform}
>
{theme.isDark ? (
<>
<stop offset="0%" stopColor={color1.darken(10).toString()} stopOpacity={1} />
<stop offset="100%" stopColor={color2.lighten(10).toString()} stopOpacity={1} />
</>
) : (
<>
<stop offset="0%" stopColor={color2.lighten(10).toString()} stopOpacity={1} />
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
</>
)}
</linearGradient>
);
return returnColor;
}
getMainBarColor(): string {
return this.getColor(this.options.fieldDisplay.display.color ?? FALLBACK_COLOR);
}
getDefs(): React.ReactNode[] {
return this.defs;
}
}
@@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema';
import { useTheme2 } from '../../themes/ThemeContext';
import { Stack } from '../Layout/Stack/Stack';
import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge';
import { RadialGauge, RadialGaugeProps } from './RadialGauge';
import { RadialShape, RadialTextMode } from './types';
interface StoryProps extends RadialGaugeProps {
value: number;
@@ -31,10 +32,27 @@ const meta: Meta<StoryProps> = {
controls: {
exclude: ['theme', 'values', 'vizCount'],
},
a11y: {
config: {
rules: [
{
id: 'scrollable-region-focusable',
selector: 'body',
enabled: false,
},
// NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples.
// The color-contrast in this component should be accessible!
{
id: 'color-contrast',
selector: 'text',
enabled: false,
},
],
},
},
},
args: {
barWidthFactor: 0.2,
spotlight: false,
glowBar: false,
glowCenter: false,
sparkline: false,
@@ -42,7 +60,7 @@ const meta: Meta<StoryProps> = {
width: 200,
height: 200,
shape: 'circle',
gradient: 'none',
gradient: false,
seriesCount: 1,
segmentCount: 0,
segmentSpacing: 0.2,
@@ -56,14 +74,14 @@ const meta: Meta<StoryProps> = {
width: { control: { type: 'range', min: 50, max: 600 } },
height: { control: { type: 'range', min: 50, max: 600 } },
value: { control: { type: 'range', min: 0, max: 110 } },
spotlight: { control: 'boolean' },
roundedBars: { control: 'boolean' },
sparkline: { control: 'boolean' },
thresholdsBar: { control: 'boolean' },
gradient: { control: { type: 'radio' } },
gradient: { control: { type: 'boolean' } },
seriesCount: { control: { type: 'range', min: 1, max: 20 } },
segmentCount: { control: { type: 'range', min: 0, max: 100 } },
segmentSpacing: { control: { type: 'range', min: 0, max: 1, step: 0.01 } },
endpointMarker: { control: { type: 'select' }, options: ['none', 'point', 'glow'] },
colorScheme: {
control: { type: 'select' },
options: [
@@ -102,57 +120,17 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<Stack direction={'column'} gap={3} wrap="wrap">
<div>Bar width</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
<RadialGaugeExample
seriesName="0.1"
value={args.value ?? 30}
color="blue"
gradient="auto"
barWidthFactor={0.1}
/>
<RadialGaugeExample
seriesName="0.4"
value={args.value ?? 40}
color="green"
gradient="auto"
barWidthFactor={0.4}
/>
<RadialGaugeExample
seriesName="0.6"
value={args.value ?? 60}
color="red"
gradient="auto"
barWidthFactor={0.6}
/>
<RadialGaugeExample
seriesName="0.8"
value={args.value ?? 70}
color="purple"
gradient="auto"
barWidthFactor={0.8}
/>
<RadialGaugeExample seriesName="0.1" value={args.value ?? 30} color="blue" gradient barWidthFactor={0.1} />
<RadialGaugeExample seriesName="0.4" value={args.value ?? 40} color="green" gradient barWidthFactor={0.4} />
<RadialGaugeExample seriesName="0.6" value={args.value ?? 60} color="red" gradient barWidthFactor={0.6} />
<RadialGaugeExample seriesName="0.8" value={args.value ?? 70} color="purple" gradient barWidthFactor={0.8} />
</Stack>
<div>Effects</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
<RadialGaugeExample value={args.value ?? 30} spotlight glowBar glowCenter color="blue" gradient="auto" />
<RadialGaugeExample value={args.value ?? 40} spotlight glowBar glowCenter color="green" gradient="auto" />
<RadialGaugeExample
value={args.value ?? 60}
spotlight
glowBar
glowCenter
color="red"
gradient="auto"
roundedBars
/>
<RadialGaugeExample
value={args.value ?? 70}
spotlight
glowBar
glowCenter
color="purple"
gradient="auto"
roundedBars
/>
<RadialGaugeExample value={args.value ?? 30} glowBar glowCenter color="blue" gradient />
<RadialGaugeExample value={args.value ?? 40} glowBar glowCenter color="green" gradient />
<RadialGaugeExample value={args.value ?? 60} glowBar glowCenter color="red" gradient roundedBars />
<RadialGaugeExample value={args.value ?? 70} glowBar glowCenter color="purple" gradient roundedBars />
</Stack>
<div>Shape: Gauge & color scale</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
@@ -160,14 +138,14 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={40}
shape="gauge"
width={250}
gradient="auto"
gradient
colorScheme={FieldColorModeId.ContinuousGrYlRd}
glowCenter={true}
barWidthFactor={0.6}
/>
<RadialGaugeExample
colorScheme={FieldColorModeId.ContinuousGrYlRd}
gradient="auto"
gradient
width={250}
value={90}
barWidthFactor={0.6}
@@ -183,9 +161,8 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 70}
color="blue"
shape="gauge"
gradient="auto"
gradient
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.2}
@@ -194,9 +171,8 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 30}
color="green"
shape="gauge"
gradient="auto"
gradient
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.8}
@@ -206,9 +182,8 @@ export const Examples: StoryFn<StoryProps> = (args) => {
color="red"
shape="gauge"
width={250}
gradient="auto"
gradient
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.2}
@@ -218,9 +193,8 @@ export const Examples: StoryFn<StoryProps> = (args) => {
color="red"
width={250}
shape="gauge"
gradient="auto"
gradient
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.8}
@@ -231,7 +205,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 70}
color="green"
gradient="auto"
gradient
glowCenter={true}
segmentCount={8}
segmentSpacing={0.1}
@@ -240,7 +214,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 30}
color="purple"
gradient="auto"
gradient
segmentCount={30}
glowCenter={true}
barWidthFactor={0.6}
@@ -248,7 +222,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 50}
color="red"
gradient="auto"
gradient
segmentCount={40}
glowCenter={true}
barWidthFactor={1}
@@ -260,7 +234,6 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 80}
colorScheme={FieldColorModeId.ContinuousGrYlRd}
spotlight
glowBar={true}
glowCenter={true}
segmentCount={20}
@@ -270,9 +243,8 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 80}
width={250}
colorScheme={FieldColorModeId.ContinuousGrYlRd}
spotlight
shape="gauge"
gradient="auto"
gradient
glowBar={true}
glowCenter={true}
segmentCount={40}
@@ -285,10 +257,9 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 70}
colorScheme={FieldColorModeId.Thresholds}
gradient="auto"
gradient
thresholdsBar={true}
roundedBars={false}
spotlight
glowCenter={true}
barWidthFactor={0.7}
/>
@@ -296,7 +267,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 70}
width={250}
colorScheme={FieldColorModeId.Thresholds}
gradient="auto"
gradient
glowCenter={true}
thresholdsBar={true}
roundedBars={false}
@@ -307,7 +278,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 70}
width={250}
colorScheme={FieldColorModeId.Thresholds}
gradient="auto"
gradient
glowCenter={true}
thresholdsBar={true}
roundedBars={false}
@@ -347,14 +318,12 @@ export const Temp: StoryFn<StoryProps> = (args) => {
shape="gauge"
roundedBars={false}
barWidthFactor={0.8}
spotlight
/>
</Stack>
);
};
interface ExampleProps {
gradient?: RadialGradientMode;
color?: string;
seriesName?: string;
value?: number;
@@ -363,7 +332,7 @@ interface ExampleProps {
max?: number;
width?: number;
height?: number;
spotlight?: boolean;
gradient?: boolean;
glowBar?: boolean;
glowCenter?: boolean;
barWidthFactor?: number;
@@ -376,12 +345,12 @@ interface ExampleProps {
roundedBars?: boolean;
thresholdsBar?: boolean;
colorScheme?: FieldColorModeId;
endpointMarker?: RadialGaugeProps['endpointMarker'];
decimals?: number;
showScaleLabels?: boolean;
}
export function RadialGaugeExample({
gradient = 'none',
color,
seriesName = 'Server A',
value = 70,
@@ -390,7 +359,7 @@ export function RadialGaugeExample({
max = 100,
width = 200,
height = 200,
spotlight = false,
gradient = false,
glowBar = false,
glowCenter = false,
barWidthFactor = 0.4,
@@ -403,6 +372,7 @@ export function RadialGaugeExample({
roundedBars = false,
thresholdsBar = false,
colorScheme = FieldColorModeId.Thresholds,
endpointMarker = 'glow',
decimals = 0,
showScaleLabels,
}: ExampleProps) {
@@ -480,7 +450,6 @@ export function RadialGaugeExample({
barWidthFactor={barWidthFactor}
gradient={gradient}
shape={shape}
spotlight={spotlight}
glowBar={glowBar}
glowCenter={glowCenter}
textMode={textMode}
@@ -490,6 +459,7 @@ export function RadialGaugeExample({
roundedBars={roundedBars}
thresholdsBar={thresholdsBar}
showScaleLabels={showScaleLabels}
endpointMarker={endpointMarker}
/>
);
}
@@ -1,13 +1,28 @@
import { render, screen } from '@testing-library/react';
import { ComponentProps } from 'react';
import { RadialGaugeExample } from './RadialGauge.story';
describe('RadialGauge', () => {
it('should render', () => {
render(<RadialGaugeExample />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it.each([
{ description: 'default', props: {} },
{ description: 'gauge shape', props: { shape: 'gauge' } },
{ description: 'with gradient', props: { gradient: true } },
{ description: 'with glow bar', props: { glowBar: true } },
{ description: 'with glow center', props: { glowCenter: true } },
{ description: 'with segments', props: { segmentCount: 5 } },
{ description: 'with rounded bars', props: { roundedBars: true } },
{ description: 'with endpoint marker glow', props: { roundedBars: true, endpointMarker: 'glow' } },
{ description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } },
{ description: 'with thresholds bar', props: { thresholdsBar: true } },
{ description: 'with sparkline', props: { sparkline: true } },
] satisfies Array<{ description: string; props?: ComponentProps<typeof RadialGaugeExample> }>)(
'should render $description without throwing',
({ props }) => {
render(<RadialGaugeExample {...props} />);
expect(screen.getByRole('img')).toBeInTheDocument();
}
);
it('should render threshold labels', () => {
render(<RadialGaugeExample showScaleLabels={true} />);
@@ -1,14 +1,7 @@
import { css, cx } from '@emotion/css';
import { isNumber } from 'lodash';
import { useId } from 'react';
import {
DisplayValueAlignmentFactors,
FieldDisplay,
getDisplayProcessor,
GrafanaTheme2,
TimeRange,
} from '@grafana/data';
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
@@ -16,12 +9,13 @@ import { getFormattedThresholds } from '../Gauge/utils';
import { RadialBar } from './RadialBar';
import { RadialBarSegmented } from './RadialBarSegmented';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialScaleLabels } from './RadialScaleLabels';
import { RadialSparkline } from './RadialSparkline';
import { RadialText } from './RadialText';
import { ThresholdsBar } from './ThresholdsBar';
import { buildGradientColors } from './colors';
import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects';
import { RadialShape, RadialTextMode } from './types';
import { calculateDimensions, getValueAngleForValue } from './utils';
export interface RadialGaugeProps {
@@ -32,7 +26,7 @@ export interface RadialGaugeProps {
* Circle or gauge (partial circle)
*/
shape?: RadialShape;
gradient?: RadialGradientMode;
gradient?: boolean;
/**
* Bar width is always relative to size of the gauge.
* But this gives you control over the width relative to size.
@@ -40,12 +34,14 @@ export interface RadialGaugeProps {
* Defaults to 0.4
**/
barWidthFactor?: number;
/** Adds a white spotlight for the end position */
spotlight?: boolean;
glowBar?: boolean;
glowCenter?: boolean;
roundedBars?: boolean;
thresholdsBar?: boolean;
/**
* Specify if an endpoint marker should be shown at the end of the bar
*/
endpointMarker?: 'point' | 'glow';
/**
* Number of segments depends on size of gauge but this
* factor 1-10 gives you relative control
@@ -75,10 +71,6 @@ export interface RadialGaugeProps {
timeRange?: TimeRange;
}
export type RadialGradientMode = 'none' | 'auto';
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
export type RadialShape = 'circle' | 'gauge';
/**
* https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs
*/
@@ -87,9 +79,8 @@ export function RadialGauge(props: RadialGaugeProps) {
width = 256,
height = 256,
shape = 'circle',
gradient = 'none',
gradient = false,
barWidthFactor = 0.4,
spotlight = false,
glowBar = false,
glowCenter = false,
textMode = 'auto',
@@ -99,6 +90,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars = true,
thresholdsBar = false,
showScaleLabels = false,
endpointMarker,
onClick,
values,
} = props;
@@ -121,7 +113,8 @@ export function RadialGauge(props: RadialGaugeProps) {
for (let barIndex = 0; barIndex < values.length; barIndex++) {
const displayValue = values[barIndex];
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
const color = displayValue.display.color ?? 'gray';
const gradientStops = buildGradientColors(gradient, theme, displayValue);
const color = displayValue.display.color ?? FALLBACK_COLOR;
const dimensions = calculateDimensions(
width,
height,
@@ -134,20 +127,12 @@ export function RadialGauge(props: RadialGaugeProps) {
showScaleLabels
);
const displayProcessor = getFieldDisplayProcessor(displayValue);
// FIXME: I want to move the ids for these filters into a context which the children
// can reference via a hook, rather than passing them down as props
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
const glowFilterId = `glow-${gaugeId}`;
const colorDefs = new RadialColorDefs({
gradient,
fieldDisplay: displayValue,
theme,
dimensions,
shape,
gaugeId,
displayProcessor,
});
if (spotlight && theme.isDark) {
if (endpointMarker === 'glow') {
defs.push(
<SpotlightGradient
key={spotlightGradientId}
@@ -171,7 +156,8 @@ export function RadialGauge(props: RadialGaugeProps) {
glowFilter={`url(#${glowFilterId})`}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
colorDefs={colorDefs}
shape={shape}
gradient={gradientStops}
/>
);
} else {
@@ -179,13 +165,16 @@ export function RadialGauge(props: RadialGaugeProps) {
<RadialBar
key={`radial-bar-${barIndex}-${gaugeId}`}
dimensions={dimensions}
colorDefs={colorDefs}
angle={angle}
angleRange={angleRange}
startAngle={startAngle}
roundedBars={roundedBars}
spotlightStroke={`url(#${spotlightGradientId})`}
glowFilter={`url(#${glowFilterId})`}
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
shape={shape}
gradient={gradientStops}
fieldDisplay={displayValue}
endpointMarker={endpointMarker}
/>
);
}
@@ -245,7 +234,8 @@ export function RadialGauge(props: RadialGaugeProps) {
angleRange={angleRange}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
colorDefs={colorDefs}
shape={shape}
gradient={gradientStops}
/>
);
}
@@ -291,17 +281,6 @@ export function RadialGauge(props: RadialGaugeProps) {
);
}
function getFieldDisplayProcessor(displayValue: FieldDisplay) {
if (displayValue.view && isNumber(displayValue.colIndex)) {
const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex);
if (dp) {
return dp;
}
}
return getDisplayProcessor();
}
function getStyles(theme: GrafanaTheme2) {
return {
vizWrapper: css({
@@ -1,87 +1,84 @@
import { memo } from 'react';
import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data';
import { t } from '@grafana/i18n';
import { measureText } from '../../utils/measureText';
import { GaugeDimensions, toCartesian } from './utils';
import { RadialGaugeDimensions } from './types';
import { getFieldConfigMinMax, toCartesian } from './utils';
interface RadialScaleLabelsProps {
fieldDisplay: FieldDisplay;
theme: GrafanaTheme2;
thresholds: Threshold[];
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
startAngle: number;
endAngle: number;
angleRange: number;
}
export function RadialScaleLabels({
fieldDisplay,
thresholds,
theme,
dimensions,
startAngle,
endAngle,
angleRange,
}: RadialScaleLabelsProps) {
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
const LINE_HEIGHT_FACTOR = 1.2;
const fieldConfig = fieldDisplay.field;
const min = fieldConfig.min ?? 0;
const max = fieldConfig.max ?? 100;
export const RadialScaleLabels = memo(
({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => {
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
const [min, max] = getFieldConfigMinMax(fieldDisplay);
const fontSize = scaleLabelsFontSize;
const textLineHeight = scaleLabelsFontSize * 1.2;
const radius = scaleLabelsRadius - textLineHeight;
const fontSize = scaleLabelsFontSize;
const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR;
const radius = scaleLabelsRadius - textLineHeight;
function getTextPosition(text: string, value: number, index: number) {
const isLast = index === thresholds.length - 1;
const isFirst = index === 0;
function getTextPosition(text: string, value: number, index: number) {
const isLast = index === thresholds.length - 1;
const isFirst = index === 0;
let valueDeg = ((value - min) / (max - min)) * angleRange;
let finalAngle = startAngle + valueDeg;
let valueDeg = ((value - min) / (max - min)) * angleRange;
let finalAngle = startAngle + valueDeg;
// Now adjust the final angle based on the label text width and the labels position on the arc
let measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
// Now adjust the final angle based on the label text width and the labels position on the arc
let measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
// the centering is different for gauge or circle shapes for some reason
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
// the centering is different for gauge or circle shapes for some reason
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
// For circle gauges we need to shift the first label more
if (isFirst) {
finalAngle += textWidthAngle;
// For circle gauges we need to shift the first label more
if (isFirst) {
finalAngle += textWidthAngle;
}
// For circle gauges we need to shift the last label more
if (isLast && endAngle === 360) {
finalAngle -= textWidthAngle;
}
const position = toCartesian(centerX, centerY, radius, finalAngle);
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
}
// For circle gauges we need to shift the last label more
if (isLast && endAngle === 360) {
finalAngle -= textWidthAngle;
}
const position = toCartesian(centerX, centerY, radius, finalAngle);
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
return (
<g>
{thresholds.map((threshold, index) => {
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
return (
<text
key={index}
x={labelPos.x}
y={labelPos.y}
fontSize={fontSize}
fill={theme.colors.text.primary}
transform={labelPos.transform}
aria-label={t(`gauge.threshold`, 'Threshold {{value}}', { value: threshold.value })}
>
{threshold.value}
</text>
);
})}
</g>
);
}
);
return (
<g>
{thresholds.map((threshold, index) => {
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
return (
<text
key={index}
x={labelPos.x}
y={labelPos.y}
fontSize={fontSize}
fill={theme.colors.text.primary}
transform={labelPos.transform}
aria-label={t(`gauge.threshold`, 'Threshold {{value}}', { value: threshold.value })}
>
{threshold.value}
</text>
);
})}
</g>
);
}
RadialScaleLabels.displayName = 'RadialScaleLabels';
@@ -1,49 +1,76 @@
import { memo, useMemo } from 'react';
import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data';
import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema';
import { Sparkline } from '../Sparkline/Sparkline';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
interface RadialSparklineProps {
sparkline: FieldDisplay['sparkline'];
dimensions: GaugeDimensions;
theme: GrafanaTheme2;
color?: string;
shape?: RadialShape;
dimensions: RadialGaugeDimensions;
shape: RadialShape;
sparkline: FieldDisplay['sparkline'];
textMode: Exclude<RadialTextMode, 'auto'>;
theme: GrafanaTheme2;
}
export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) {
const { radius, barWidth } = dimensions;
if (!sparkline) {
return null;
const SPARKLINE_HEIGHT_DIVISOR = 4;
const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4;
const SPARKLINE_WIDTH_FACTOR_ARC = 1.4;
const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6;
const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4;
const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3;
const SPARKLINE_SPACING = 8;
export function getSparklineDimensions(
radius: number,
barWidth: number,
showNameAndValue: boolean,
shape: RadialShape
): { width: number; height: number } {
const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR);
const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth;
return { width, height };
}
export const RadialSparkline = memo(
({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) => {
const { radius, barWidth } = dimensions;
const showNameAndValue = textMode === 'value_and_name';
const { width, height } = getSparklineDimensions(radius, barWidth, showNameAndValue, shape);
const topPos =
shape === 'gauge'
? dimensions.gaugeBottomY - height - SPARKLINE_SPACING
: `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`;
const config: FieldConfig<GraphFieldConfig> = useMemo(
() => ({
color: {
mode: 'fixed',
fixedColor: color ?? 'blue',
},
custom: {
gradientMode: GraphGradientMode.Opacity,
fillOpacity: 40,
lineInterpolation: LineInterpolation.Smooth,
},
}),
[color]
);
if (!sparkline) {
return null;
}
return (
<div style={{ position: 'absolute', top: topPos }}>
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
</div>
);
}
);
const showNameAndValue = textMode === 'value_and_name';
const height = radius / (showNameAndValue ? 4 : 3);
const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth;
const topPos =
shape === 'gauge'
? `${dimensions.gaugeBottomY - height}px`
: `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`;
const config: FieldConfig<GraphFieldConfig> = {
color: {
mode: 'fixed',
fixedColor: color ?? 'blue',
},
custom: {
gradientMode: GraphGradientMode.Opacity,
fillOpacity: 40,
lineInterpolation: LineInterpolation.Smooth,
},
};
return (
<div style={{ position: 'absolute', top: topPos }}>
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
</div>
);
}
RadialSparkline.displayName = 'RadialSparkline';
@@ -1,4 +1,5 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import {
DisplayValue,
@@ -11,13 +12,12 @@ import {
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
interface RadialTextProps {
displayValue: DisplayValue;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
textMode: Exclude<RadialTextMode, 'auto'>;
shape: RadialShape;
sparkline?: FieldSparkline;
@@ -26,123 +26,137 @@ interface RadialTextProps {
nameManualFontSize?: number;
}
export function RadialText({
displayValue,
theme,
dimensions,
textMode,
shape,
sparkline,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
const LINE_HEIGHT_FACTOR = 1.21;
const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82;
const NAME_TO_HEIGHT_FACTOR = 0.45;
const LARGE_RADIUS_SCALING_DECAY = 0.86;
const MAX_TEXT_WIDTH_DIVISOR = 7;
const MAX_NAME_HEIGHT_DIVISOR = 4;
const VALUE_SPACE_PERCENTAGE = 0.7;
const SPARKLINE_SPACING = 8;
const MIN_VALUE_FONT_SIZE = 1;
const MIN_NAME_FONT_SIZE = 10;
const MIN_UNIT_FONT_SIZE = 6;
if (textMode === 'none') {
return null;
}
export const RadialText = memo(
({
displayValue,
theme,
dimensions,
textMode,
shape,
sparkline,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) => {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
if (textMode === 'none') {
return null;
}
const showValue = textMode === 'value' || textMode === 'value_and_name';
const showName = textMode === 'name' || textMode === 'value_and_name';
const maxTextWidth = radius * 2 - barWidth - radius / 7;
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
// Not sure where this comes from but svg text is not using body line-height
const lineHeight = 1.21;
const valueWidthToRadiusFactor = 0.82;
const nameToHeightFactor = 0.45;
const largeRadiusScalingDecay = 0.86;
const showValue = textMode === 'value' || textMode === 'value_and_name';
const showName = textMode === 'name' || textMode === 'value_and_name';
const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR;
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
let maxNameHeight = radius / 4;
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR;
if (showValue && showName) {
maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay);
}
if (showValue && showName) {
maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
}
const valueFontSize =
valueManualFontSize ??
calculateFontSize(
valueToAlignTo,
maxTextWidth,
maxValueHeight,
lineHeight,
undefined,
theme.typography.body.fontWeight
const valueFontSize = Math.max(
valueManualFontSize ??
calculateFontSize(
valueToAlignTo,
maxTextWidth,
maxValueHeight,
LINE_HEIGHT_FACTOR,
undefined,
theme.typography.body.fontWeight
),
MIN_VALUE_FONT_SIZE
);
const nameFontSize =
nameManualFontSize ??
calculateFontSize(
nameToAlignTo,
maxTextWidth,
maxNameHeight,
lineHeight,
undefined,
theme.typography.body.fontWeight
const nameFontSize = Math.max(
nameManualFontSize ??
calculateFontSize(
nameToAlignTo,
maxTextWidth,
maxNameHeight,
LINE_HEIGHT_FACTOR,
undefined,
theme.typography.body.fontWeight
),
MIN_NAME_FONT_SIZE
);
const unitFontSize = Math.max(valueFontSize * 0.7, 5);
const valueHeight = valueFontSize * lineHeight;
const nameHeight = nameFontSize * lineHeight;
const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE);
const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR;
const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR;
const valueY = showName ? centerY - nameHeight * 0.3 : centerY;
const nameY = showValue ? valueY + valueHeight * 0.7 : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2;
const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY;
const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
}
if (sparkline) {
yOffset -= 8;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
}
if (sparkline) {
yOffset -= SPARKLINE_SPACING;
}
return (
<g transform={`translate(0, ${yOffset})`}>
{showValue && (
<text
x={centerX}
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
{displayValue.suffix ?? ''}
</tspan>
</text>
)}
{showName && (
<text
fontSize={nameFontSize}
x={centerX}
y={nameY}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
>
{displayValue.title}
</text>
)}
</g>
);
}
);
return (
<g transform={`translate(0, ${yOffset})`}>
{showValue && (
<text
x={centerX}
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
{displayValue.suffix ?? ''}
</tspan>
</text>
)}
{showName && (
<text
fontSize={nameFontSize}
x={centerX}
y={nameY}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
>
{displayValue.title}
</text>
)}
</g>
);
}
RadialText.displayName = 'RadialText';
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = (_theme: GrafanaTheme2) => ({
text: css({
verticalAlign: 'bottom',
}),
@@ -1,20 +1,22 @@
import { FieldDisplay, Threshold } from '@grafana/data';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions } from './utils';
import { GradientStop, RadialGaugeDimensions, RadialShape } from './types';
import { getFieldConfigMinMax } from './utils';
export interface Props {
dimensions: GaugeDimensions;
interface ThresholdsBarProps {
dimensions: RadialGaugeDimensions;
angleRange: number;
startAngle: number;
endAngle: number;
shape: RadialShape;
fieldDisplay: FieldDisplay;
roundedBars?: boolean;
glowFilter?: string;
colorDefs: RadialColorDefs;
thresholds: Threshold[];
gradient?: GradientStop[];
}
export function ThresholdsBar({
dimensions,
fieldDisplay,
@@ -22,19 +24,18 @@ export function ThresholdsBar({
angleRange,
roundedBars,
glowFilter,
colorDefs,
thresholds,
}: Props) {
const fieldConfig = fieldDisplay.field;
const min = fieldConfig.min ?? 0;
const max = fieldConfig.max ?? 100;
shape,
gradient,
}: ThresholdsBarProps) {
const thresholdDimensions = {
...dimensions,
barWidth: dimensions.thresholdsBarWidth,
radius: dimensions.thresholdsBarRadius,
};
const [min, max] = getFieldConfigMinMax(fieldDisplay);
let currentStart = startAngle;
let paths: React.ReactNode[] = [];
@@ -48,27 +49,26 @@ export function ThresholdsBar({
valueDeg = 0;
}
let lengthDeg = valueDeg - currentStart + startAngle;
const lengthDeg = valueDeg - currentStart + startAngle;
const colorProps = gradient ? { gradient } : { color: threshold.color };
paths.push(
<RadialArcPath
key={i}
startAngle={currentStart}
arcLengthDeg={lengthDeg}
barEndcaps={shape === 'circle' && roundedBars}
dimensions={thresholdDimensions}
roundedBars={roundedBars}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
color={colorDefs.getColor(threshold.color, true)}
roundedBars={roundedBars}
shape={shape}
startAngle={currentStart}
{...colorProps}
/>
);
currentStart += lengthDeg;
}
return (
<>
<g>{paths}</g>
<defs>{colorDefs.getDefs()}</defs>
</>
);
return <g>{paths}</g>;
}
@@ -0,0 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 1`] = `
[
{
"color": "#444444",
"percent": 0,
},
{
"color": "#FADE2A",
"percent": 0.5,
},
{
"color": "#F2495C",
"percent": 0.8,
},
{
"color": "#444444",
"percent": 1,
},
]
`;
exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 2`] = `
[
{
"color": "#FF0000",
"percent": 0,
},
{
"color": "#FADE2A",
"percent": 0.5,
},
{
"color": "#F2495C",
"percent": 0.8,
},
{
"color": "#FF0000",
"percent": 1,
},
]
`;
exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in dark theme 1`] = `
[
{
"color": "#181b1f",
"percent": 0,
},
{
"color": "#1F60C4",
"percent": 1,
},
]
`;
exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in light theme 1`] = `
[
{
"color": "#ffffff",
"percent": 0,
},
{
"color": "#1250B0",
"percent": 1,
},
]
`;
exports[`RadialGauge color utils buildGradientColors should return gradient colors for continuous color modes 1`] = `
[
{
"color": "rgb(0, 32, 81)",
"percent": 0,
},
{
"color": "rgb(17, 54, 108)",
"percent": 0.125,
},
{
"color": "rgb(60, 77, 110)",
"percent": 0.25,
},
{
"color": "rgb(98, 100, 111)",
"percent": 0.375,
},
{
"color": "rgb(127, 124, 117)",
"percent": 0.5,
},
{
"color": "rgb(154, 148, 120)",
"percent": 0.625,
},
{
"color": "rgb(187, 175, 113)",
"percent": 0.75,
},
{
"color": "rgb(226, 203, 92)",
"percent": 0.875,
},
{
"color": "rgb(253, 234, 69)",
"percent": 1,
},
]
`;
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = `
[
{
"color": "#37237a",
"percent": 0,
},
{
"color": "#a146da",
"percent": 0.75,
},
{
"color": "#a146da",
"percent": 1,
},
]
`;
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = `
[
{
"color": "#a146da",
"percent": 0,
},
{
"color": "#3e2b9a",
"percent": 0.75,
},
{
"color": "#3e2b9a",
"percent": 1,
},
]
`;
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`;

Some files were not shown because too many files have changed in this diff Show More