Compare commits

..
Author SHA1 Message Date
Alejandro Fraenkel e4265fe39c fix(alerting): remove obsolete notification templates tab checks from ContactPoints tests
- Remove test code checking for Notification Templates tab (lines 282-288 and 494-497)
- Templates are now a separate page in sidebar navigation, not a tab within Contact Points
- Tests should only verify contact points permissions, not templates
2026-01-13 17:09:16 +01:00
Alejandro Fraenkel 2635a67630 fix: resolve CI failures from navigation refactoring
TypeScript fixes:
- Add 'as const' to icon type in useAlertRulesNav.test.tsx for proper type narrowing
- Remove unused imports in NotificationPoliciesPage.test.tsx and ContactPoints.test.tsx
- Remove unused import in triage/Triage.tsx
- Restore missing useURLSearchParams import in ContactPoints.tsx
- Restore ActiveTab enum export (used by template pages for URL generation)

Go lint fixes:
- Move nolint:staticcheck comment to line directly before flagged usage in navtree.go

i18n:
- Run yarn i18n-extract to update translation files

All tests passing:
- Backend navtree tests: 
- TypeScript compilation: 
2026-01-13 13:12:24 +01:00
Alejandro Fraenkel 30bacc4ef1 refactor(alerting): format navtree child items for consistency
- Convert child NavLink definitions to multi-line format
- Remove redundant inline comments
- All navtree tests passing (backend and frontend)
2026-01-13 01:39:37 +01:00
Alejandro Fraenkel 143c256a93 fix: remove unused imports in Home.tsx 2026-01-13 01:01:02 +01:00
Alejandro Fraenkel cc556f3792 Merge branch 'main' into alerting/menu-v2-only
Resolved conflicts:
- pkg/services/navtree/navtreeimpl/navtree.go: Kept V2 navigation structure with proper parent-child hierarchy for alert-activity
- public/app/features/alerting/unified/rule-list/RuleList.v2.tsx: Merged both import statements (useAlertRulesNav and getRulesDataSources)
2026-01-12 21:20:08 +01:00
Alejandro FraenkelandClaude Sonnet 4.5 661cd58bae feat(alerting): remove alertingNavigationV2 feature flag
Remove the alertingNavigationV2 feature toggle and make V2 navigation the only option.

Changes:
- Remove feature flag from registry.go and regenerate toggle files
- Remove shouldUseAlertingNavigationV2() function from featureToggles.ts
- Clean up all navigation hooks (useNotificationConfigNav, useInsightsNav, useAlertRulesNav, useAlertActivityNav)
- Remove legacy navigation logic from page components (TimeIntervalsPage, Templates)
- Update MegaMenu to always use flattened alerting sidebar navigation
- Simplify all test files by removing feature flag mocking and legacy test cases

Net result: ~500 lines of code removed, simpler codebase with single navigation implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 21:12:32 +01:00
Alejandro FraenkelandClaude Sonnet 4.5 b3625b95e3 Remove feature flag and legacy navigation code
This commit removes the alertingNavigationV2 feature flag and all
legacy navigation code, making the V2 navigation the only option.

This simplifies the codebase by removing:
- Feature flag checks in backend and frontend
- buildAlertNavLinksLegacy function (114 lines)
- Legacy tab code in ContactPoints and NotificationPoliciesPage
- Related test code for legacy navigation

Backend changes:
- navtree.go: Remove feature flag check, delete legacy function
- navtree_alerting_test.go: Remove legacy tests, update V2 tests

Frontend changes:
- Home.tsx: Remove feature flag, Insights tab always hidden
- ContactPoints.tsx: Remove tabs, show only contact points
- NotificationPoliciesPage.tsx: Remove tabs, show only policies

This branch is designed for a one-time change to reduce change
surface compared to maintaining both legacy and V2 navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 19:19:59 +01:00
Alejandro FraenkelandClaude Sonnet 4.5 774551589b Fix spacing in Notification Templates tab (legacy mode)
Add proper top margin to TabContent in legacy mode to match the
spacing pattern used in Notification Policies page.

Changes:
- Import css from @emotion/css and useStyles2
- Import GrafanaTheme2 for theme typing
- Create getStyles function with tabContent margin
- Apply className to TabContent in legacy mode rendering
- Matches the pattern used in NotificationPoliciesPage.tsx

This fixes the visual issue where the "Create notification templates"
text was directly touching the tabs above with no spacing in legacy
navigation mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 15:32:06 +01:00
Alejandro FraenkelandClaude Sonnet 4.5 dee9bc8fb9 Fix spacing issues in Contact Points and Templates tabs
Add proper top spacing to Contact Points and Notification Templates
tabs to match the spacing pattern used in Notification Policies.

Changes:
- Wrap ContactPointsTab content in Stack with gap={1}
- Wrap NotificationTemplatesTab content in Stack with gap={1}
- This adds consistent spacing between the tabs and the search/filter
  sections, matching the UX pattern in Notification Policies

Fixes visual regression where search boxes were directly touching
the tab bar with no spacing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 15:29:05 +01:00
Alejandro FraenkelandClaude Sonnet 4.5 6395a753d8 Hide Insights tab on Home page when V2 navigation is enabled
When alertingNavigationV2 feature flag is enabled, remove the Insights
tab from the Home page since Insights is now available as a dedicated
section in the sidebar navigation.

Changes:
- Add check for alertingNavigationV2 feature flag in Home.tsx
- When V2 is enabled, insightsEnabled is false (no tab on Home)
- When V2 is disabled (legacy), keep current behavior (show tab if available)
- Insights content remains accessible via sidebar Insights menu in V2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 15:24:18 +01:00
Alejandro Fraenkel 5b228fd7fa Revert "Remove Insights from navigation sidebar"
This reverts commit 307cce059c.
2026-01-12 15:08:43 +01:00
Alejandro FraenkelandClaude Sonnet 4.5 307cce059c Remove Insights from navigation sidebar
Remove Insights from sidebar navigation to match main branch behavior.
Insights should remain as a tab on the Home page, not a separate
navigation item.

Changes:
- Remove Insights parent and tabs from backend navigation (navtree.go)
- Remove /alerting/insights route from routes.tsx
- Delete InsightsPage.tsx component
- Delete useInsightsNav hook and test files
- Update backend tests to remove Insights references

All navigation tests pass successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 14:50:48 +01:00
Alejandro Fraenkel a5d240751d refactor(alerting): rename V2 nav function to main name for easier future cleanup
Make the V2 navigation implementation the main buildAlertNavLinks() function,
and keep buildAlertNavLinksLegacy() with its descriptive name.

This makes future cleanup trivial:
- To remove legacy support: just delete buildAlertNavLinksLegacy() and the
  feature flag check
- No need to rename functions later
- Main function already has the desired implementation

Changes:
- Inline V2 implementation into buildAlertNavLinks()
- Delete buildAlertNavLinksV2() function (now redundant)
- Update tests to call buildAlertNavLinks() directly
- Inverted feature flag check (!enabled instead of enabled)

All tests pass with identical coverage.
2026-01-12 14:14:30 +01:00
Alejandro Fraenkel d93867479f refactor(alerting): optimize navtree alerting tests for better maintainability
Reduce test file from 393 to 233 lines (-41%) while maintaining full coverage:

- Extract common test fixtures (setupTestContext, setupTestService, fullPermissions)
- Add reusable helper functions (findNavLink, hasChildWithId)
- Convert repetitive tab tests to table-driven approach
- Improve code readability and maintainability

All 8 test scenarios still verify:
- Feature flag toggle (legacy vs V2)
- Navigation structure and permissions for both modes
- V2 parent/tab structure
- Permission enforcement
- Future-proofing

Benefits:
- 41% code reduction (160 lines removed)
- Same test coverage and assertions
- More idiomatic Go testing patterns
- Easier to extend with new test cases
2026-01-12 14:01:09 +01:00
Alejandro Fraenkel 9f53141368 fix(alerting): restore 'Alert activity' text in V1 navigation
Keep the original 'Alert activity' text in the legacy navigation instead
of changing it to 'Alerts'. This maintains consistency with the existing
V1 navigation experience.
2026-01-12 13:56:15 +01:00
Alejandro Fraenkel 0413b76461 chore: remove conf/defaults.ini dev changes from PR
Keep feature flags disabled by default in config.
Local dev environments can enable flags as needed.
2026-01-12 13:29:55 +01:00
Alejandro Fraenkel 417d3d914a fix(alerting): fix failing navigation and TimeIntervalsPage tests
- Fix navigation hooks tests by manually creating Redux store with configureStore
  - getWrapper doesn't use preloadedState, so we need to pass store directly
  - Updated useNotificationConfigNav, useAlertActivityNav, useAlertRulesNav, and useInsightsNav tests
- Fix TimeIntervalsPage test by:
  - Setting up navIndex in Redux store for V2 navigation
  - Mocking time intervals API with setTimeIntervalsListEmpty()
  - Using findAllByText instead of findByText for multiple matches
- Update time intervals tab detection test to use V2 path instead of query params
- All 21 previously failing tests now pass
- All 1,792 alerting tests pass successfully
2026-01-12 13:09:48 +01:00
Alejandro Fraenkel 2a7f698c4c Flatten Alerting V2 navigation sidebar while keeping tabs in page content
- Modified MegaMenu to filter out nested children for Alerting V2 navigation items
- Sidebar now shows only top-level items: Alert Activity, Alert Rules, Notification Configuration, Insights, Settings
- Tabs are still available in page content (handled by frontend navigation hooks)
- Breadcrumbs still work correctly (children available in navIndex)
- Only applies when alertingNavigationV2 feature flag is enabled
2026-01-09 11:53:36 +01:00
Alejandro Fraenkel 212bdb4400 fix(alerting): fix AlertmanagerContext error in TimeIntervalsPage
- Move useAlertmanager hook call inside AlertmanagerPageWrapper context
- Create TimeIntervalsPageContent component that uses the context
- Fixes 'useAlertmanager must be used within a AlertmanagerContext' error
- Revert incorrect changes to Templates.tsx (error was in TimeIntervalsPage)
2026-01-08 15:56:36 +01:00
Alejandro Fraenkel 2432756be8 fix(alerting): fix breadcrumb for Alert Activity page
- Use conditional navId based on feature flag (alert-activity for V2, alert-alerts for legacy)
- Remove pageNav.text to avoid extra breadcrumb level
- Use renderTitle instead to set page title without affecting breadcrumb
- Fixes breadcrumb showing 'Page not found > Alerts' to 'Alerting > Alert Activity'
2026-01-08 12:43:24 +01:00
Alejandro Fraenkel a59df66e21 fix(alerting): resolve TypeScript and linting errors in navigation hooks
- Fix icon type errors by moving type assertions to children assignment
- Add ESLint disable comments for necessary type assertions
- Fix unused imports in navigation hooks and test files
- Fix missing currentAlertmanager prop in TimeIntervalsPage
- Fix incorrect permission name in TimeIntervalsPage test
- Apply same pattern to useInsightsNav to fix type errors
2026-01-08 12:40:32 +01:00
Alejandro Fraenkel 5bec0f1af7 feat(alerting): separate notification policies and time intervals, contact points and templates
- Separate Notification policies and Time intervals into distinct tabs in V2 navigation
- Separate Contact points and Notification templates into distinct tabs in V2 navigation
- Add backward compatibility for legacy navigation
- Add TimeIntervalsPage component
- Update navigation hooks and tests
- Enable feature toggles in defaults.ini
- Fix linting errors (duplicate imports, conditional hooks)
2026-01-08 11:57:11 +01:00
Alejandro Fraenkel 954156d5b3 feat(alerting): implement grouped navigation structure with feature flag
- Add alertingNavigationV2 feature flag
- Refactor backend navigation to support legacy and V2 structures
- Create frontend navigation hooks (useAlertRulesNav, useNotificationConfigNav, useInsightsNav)
- Extract Insights component and create InsightsPage
- Update all page components to use new navigation hooks
- Add comprehensive backend and frontend tests
- Support grouped navigation with parent items and tabs
2026-01-08 00:34:41 +01:00
136 changed files with 1698 additions and 2071 deletions
+1 -7
View File
@@ -336,7 +336,7 @@ rudderstack_data_plane_url =
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_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
rudderstack_config_url =
@@ -2079,14 +2079,8 @@ enable =
# To enable features by default, set `Expression: "true"` in:
# https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go
# The feature_toggles section supports feature flags of a number of types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
#
# feature1 = true
# feature2 = false
# feature3 = "foobar"
# feature4 = 1.5
# feature5 = { "foo": "bar" }
[feature_toggles.openfeature]
# This is EXPERIMENTAL. Please, do not use this section
+3 -8
View File
@@ -323,7 +323,7 @@
;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_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
;rudderstack_config_url =
@@ -1913,7 +1913,7 @@ default_datasource_uid =
# client_queue_max_size is the maximum size in bytes of the client queue
# for Live connections. Defaults to 4MB.
;client_queue_max_size =
;client_queue_max_size =
#################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer]
@@ -1996,14 +1996,9 @@ default_datasource_uid =
;enable = feature1,feature2
# The feature_toggles section supports feature flags of a number of types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
;feature1 = true
;feature2 = false
;feature3 = "foobar"
;feature4 = 1.5
;feature5 = { "foo": "bar" }
[date_formats]
# For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/
@@ -2836,11 +2836,9 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../
Keys of features to enable, separated by space.
#### `FEATURE_NAME = <value>`
#### `FEATURE_TOGGLE_NAME = false`
Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details.
For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`.
<hr>
+5
View File
@@ -1156,6 +1156,11 @@
"count": 2
}
},
"public/app/core/config.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"public/app/core/navigation/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
+4
View File
@@ -622,6 +622,10 @@ export interface FeatureToggles {
*/
exploreLogsAggregatedMetrics?: boolean;
/**
* Used in Logs Drilldown to limit the time range
*/
exploreLogsLimitedTimeRange?: boolean;
/**
* Enables the gRPC client to authenticate with the App Platform by using ID & access tokens
*/
appPlatformGrpcClientAuth?: boolean;
@@ -14,8 +14,6 @@ export type Props = React.ComponentProps<typeof TextArea> & {
isConfigured: boolean;
/** Called when the user clicks on the "Reset" button in order to clear the secret */
onReset: () => void;
/** If true, the text area will grow to fill available width. */
grow?: boolean;
};
export const CONFIGURED_TEXT = 'configured';
@@ -37,11 +35,11 @@ const getStyles = (theme: GrafanaTheme2) => {
*
* https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs
*/
export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => {
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => {
const styles = useStyles2(getStyles);
return (
<Stack>
<Box grow={grow ? 1 : undefined}>
<Box>
{!isConfigured && <TextArea {...props} />}
{isConfigured && (
<TextArea
@@ -106,10 +106,10 @@ describe('VizTooltipFooter', () => {
</MemoryRouter>
);
const onForButton = screen.getByTestId(selectors.components.VizTooltipFooter.buttons.apply);
const onForButton = screen.getByRole('button', { name: /Apply as filter/i });
expect(onForButton).toBeInTheDocument();
const onOutButton = screen.getByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse);
const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i });
expect(onOutButton).toBeInTheDocument();
await userEvent.click(onForButton);
@@ -127,19 +127,25 @@ export const VizTooltipFooter = ({
variant="secondary"
size="sm"
onClick={filterByGroupedLabels.onFilterForGroupedLabels}
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
>
<Trans i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter">Filter on this value</Trans>
<Trans
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter"
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
>
Apply as filter
</Trans>
</Button>
<Button
icon="filter"
variant="secondary"
size="sm"
onClick={filterByGroupedLabels.onFilterOutGroupedLabels}
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
>
<Trans i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter">
Filter out this value
<Trans
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter"
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
>
Apply as inverse filter
</Trans>
</Button>
</Stack>
+8 -17
View File
@@ -552,7 +552,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
// gets dashboards that the user was granted read access to
permissions := user.GetPermissions()
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
folderPermissions := permissions[dashboards.ActionFoldersRead]
dashboardUids := make([]string, 0)
sharedDashboards := make([]string, 0)
@@ -563,13 +562,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
}
for _, folderPermission := range folderPermissions {
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
dashboardUids = append(dashboardUids, folderUid)
}
}
}
if len(dashboardUids) == 0 {
return sharedDashboards, nil
@@ -580,15 +572,9 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
return sharedDashboards, err
}
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
Federated: []*resourcepb.ResourceKey{folderKey},
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Options: &resourcepb.ListOptions{
Key: key,
Fields: []*resourcepb.Requirement{{
@@ -624,6 +610,12 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
// only folders the user has access to will be returned here
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
folderSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(allFolders)),
@@ -636,7 +628,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}},
},
}
// only folders the user has access to will be returned here
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
if err != nil {
return sharedDashboards, err
+3 -27
View File
@@ -507,15 +507,6 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("publicfolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -559,15 +550,6 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -589,7 +571,6 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
@@ -600,19 +581,14 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
// first call gets all dashboards user has permission for
firstCall := mockClient.MockCalls[0]
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
// verify federated field is set to include folders
assert.NotNil(t, firstCall.Federated)
assert.Equal(t, 1, len(firstCall.Federated))
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
// second call gets folders associated with the previous dashboards
secondCall := mockClient.MockCalls[1]
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
// permission to read
thirdCall := mockClient.MockCalls[2]
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
resp := rr.Result()
defer func() {
+1 -5
View File
@@ -133,11 +133,7 @@ type FeatureFlag struct {
Stage FeatureFlagStage `json:"stage,omitempty"`
Owner codeowner `json:"-"` // Owner person or team that owns this feature flag
// Expression defined by the feature_toggles configuration.
// Supports multiple types including boolean, string, integer, float,
// and structured values following the OpenFeature specification.
// Using the value "true" means the feature flag is enabled by default,
// Using the value "1.0" means the default value of the feature flag is 1.0
// CEL-GO expression. Using the value "true" will mean this is on by default
Expression string `json:"expression,omitempty"`
// Special behavior properties
+3 -4
View File
@@ -8,7 +8,6 @@ import (
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/open-feature/go-sdk/openfeature"
@@ -27,7 +26,7 @@ type OpenFeatureConfig struct {
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
HTTPClient *http.Client
// StaticFlags are the feature flags to use with static provider
StaticFlags map[string]memprovider.InMemoryFlag
StaticFlags map[string]bool
// TargetingKey is used for evaluation context
TargetingKey string
// ContextAttrs are additional attributes for evaluation context
@@ -101,7 +100,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
func createProvider(
providerType string,
u *url.URL,
staticFlags map[string]memprovider.InMemoryFlag,
staticFlags map[string]bool,
httpClient *http.Client,
) (openfeature.FeatureProvider, error) {
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
@@ -118,7 +117,7 @@ func createProvider(
}
}
return newStaticProvider(staticFlags, standardFeatureFlags)
return newStaticProvider(staticFlags)
}
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
+7
View File
@@ -1031,6 +1031,13 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "exploreLogsLimitedTimeRange",
Description: "Used in Logs Drilldown to limit the time range",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "appPlatformGrpcClientAuth",
Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens",
+1 -2
View File
@@ -47,8 +47,7 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
}
mgmt.warnings[key] = "unknown flag in config"
}
mgmt.startup[key] = val.Variants[val.DefaultVariant] == true
mgmt.startup[key] = val
}
// update the values
+1 -1
View File
@@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
}
staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags)
staticProvider, err := newStaticProvider(staticFlags)
if err != nil {
return nil, fmt.Errorf("failed to create static provider: %w", err)
}
+29 -18
View File
@@ -1,13 +1,8 @@
package featuremgmt
import (
"fmt"
"maps"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/setting"
)
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
@@ -33,21 +28,37 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
return keys, nil
}
func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
// Parse and add standard flags
for _, flag := range standardFlags {
inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression)
if err != nil {
return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err)
}
flags[flag.Name] = inMemFlag
}
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
// Add flags from config.ini file
maps.Copy(flags, confFlags)
for name, value := range confFlags {
flags[name] = createInMemoryFlag(name, value)
}
// Add standard flags
for _, flag := range standardFeatureFlags {
if _, exists := flags[flag.Name]; !exists {
enabled := flag.Expression == "true"
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
}
}
return newInMemoryBulkProvider(flags), nil
}
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
variant := "disabled"
if enabled {
variant = "enabled"
}
return memprovider.InMemoryFlag{
Key: name,
DefaultVariant: variant,
Variants: map[string]interface{}{
"enabled": true,
"disabled": false,
},
}
}
@@ -5,7 +5,6 @@ import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/open-feature/go-sdk/openfeature"
"github.com/stretchr/testify/assert"
@@ -94,144 +93,3 @@ ABCD = true
enabledFeatureManager := mgr.GetEnabled(ctx)
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
}
func Test_StaticProvider_TypedFlags(t *testing.T) {
tests := []struct {
flags FeatureFlag
defaultValue any
expectedValue any
}{
{
flags: FeatureFlag{
Name: "Flag",
Expression: "true",
},
defaultValue: false,
expectedValue: true,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1.0",
},
defaultValue: 0.0,
expectedValue: 1.0,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "blue",
},
defaultValue: "red",
expectedValue: "blue",
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1",
},
defaultValue: int64(0),
expectedValue: int64(1),
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: `{ "foo": "bar" }`,
},
expectedValue: map[string]any{"foo": "bar"},
},
}
for _, tt := range tests {
provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags})
assert.NoError(t, err)
var result any
switch tt.expectedValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.expectedValue, result)
}
}
func Test_StaticProvider_ConfigOverride(t *testing.T) {
tests := []struct {
name string
originalValue string
configValue any
}{
{
name: "bool",
originalValue: "false",
configValue: true,
},
{
name: "int",
originalValue: "0",
configValue: int64(1),
},
{
name: "float",
originalValue: "0.0",
configValue: 1.0,
},
{
name: "string",
originalValue: "foo",
configValue: "bar",
},
{
name: "structure",
originalValue: "{}",
configValue: make(map[string]any),
},
}
for _, tt := range tests {
configFlags, standardFlags := makeFlags(tt)
provider, err := newStaticProvider(configFlags, standardFlags)
assert.NoError(t, err)
var result any
switch tt.configValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.configValue, result)
}
}
func makeFlags(tt struct {
name string
originalValue string
configValue any
}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) {
orig := FeatureFlag{
Name: tt.name,
Expression: tt.originalValue,
}
config := map[string]memprovider.InMemoryFlag{
tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue),
}
return config, []FeatureFlag{orig}
}
+1
View File
@@ -142,6 +142,7 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false
groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false
alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
142 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
143 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
144 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true
145 exploreLogsLimitedTimeRange experimental @grafana/observability-logs false false true
146 appPlatformGrpcClientAuth experimental @grafana/identity-access-team false false false
147 groupAttributeSync privatePreview @grafana/identity-access-team false false false
148 alertingQueryAndExpressionsStepMode GA @grafana/alerting-squad false false true
+15 -2
View File
@@ -348,6 +348,20 @@
"expression": "true"
}
},
{
"metadata": {
"name": "alertingNavigationV2",
"resourceVersion": "1767827323622",
"creationTimestamp": "2026-01-07T23:08:43Z",
"deletionTimestamp": "2026-01-12T18:34:54Z"
},
"spec": {
"description": "Enable new grouped navigation structure for Alerting",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"expression": "false"
}
},
{
"metadata": {
"name": "alertingNotificationHistory",
@@ -1382,8 +1396,7 @@
"metadata": {
"name": "exploreLogsLimitedTimeRange",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-08-29T13:55:59Z",
"deletionTimestamp": "2026-01-12T22:18:14Z"
"creationTimestamp": "2024-08-29T13:55:59Z"
},
"spec": {
"description": "Used in Logs Drilldown to limit the time range",
@@ -190,6 +190,9 @@ func verifyFlagsConfiguration(t *testing.T) {
if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" {
t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name)
}
if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" {
t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name)
}
// Check camel case names
if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] {
invalidNames = append(invalidNames, flag.Name)
+143 -28
View File
@@ -436,38 +436,91 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
var alertActivityChildren []*navtree.NavLink
//nolint:staticcheck // not yet migrated to OpenFeature
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Alerts",
SubTitle: "Visualize active and pending alerts",
Id: "alert-activity-alerts",
Url: s.cfg.AppSubURL + "/alerting/alerts",
Icon: "bell",
})
}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Active notifications",
SubTitle: "See grouped alerts with active notifications",
Id: "alert-activity-groups",
Url: s.cfg.AppSubURL + "/alerting/groups",
Icon: "layer-group",
})
}
if len(alertActivityChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
Text: "Alert activity",
SubTitle: "Visualize active and pending alerts",
Id: "alert-activity",
Url: s.cfg.AppSubURL + "/alerting/alerts",
Icon: "bell",
IsNew: true,
Children: alertActivityChildren,
})
}
}
var alertRulesChildren []*navtree.NavLink
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{
Text: "Alert rules",
SubTitle: "Rules that determine whether an alert will fire",
Id: "alert-rules-list",
Url: s.cfg.AppSubURL + "/alerting/list",
Icon: "list-ul",
})
}
//nolint:staticcheck // not yet migrated to OpenFeature
if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) {
alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
Id: "alert-rules-recently-deleted",
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
})
}
if len(alertRulesChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules",
SubTitle: "Manage alert and recording rules",
Id: "alert-rules",
Url: s.cfg.AppSubURL + "/alerting/list",
Icon: "list-ul",
Children: alertRulesChildren,
})
}
var notificationConfigChildren []*navtree.NavLink
contactPointsPerms := []ac.Evaluator{
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
ac.EvalPermission(ac.ActionAlertingReceiversRead),
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
ac.EvalPermission(ac.ActionAlertingReceiversCreate),
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesRead),
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesWrite),
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesDelete),
}
if hasAccess(ac.EvalAny(contactPointsPerms...)) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Contact points", SubTitle: "Choose how to notify your contact points when an alert instance fires", Id: "receivers", Url: s.cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Contact points",
SubTitle: "Choose how to notify your contact points when an alert instance fires",
Id: "notification-config-contact-points",
Url: s.cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
}
@@ -479,54 +532,116 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
)) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "am-routes", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Notification policies",
SubTitle: "Determine how alerts are routed to contact points",
Id: "notification-config-policies",
Url: s.cfg.AppSubURL + "/alerting/routes",
Icon: "sitemap",
})
}
if hasAccess(ac.EvalAny(contactPointsPerms...)) {
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Notification templates",
SubTitle: "Manage notification templates",
Id: "notification-config-templates",
Url: s.cfg.AppSubURL + "/alerting/notifications/templates",
Icon: "file-alt",
})
}
if hasAccess(ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceRead),
ac.EvalPermission(ac.ActionAlertingInstancesExternalRead),
ac.EvalPermission(ac.ActionAlertingSilencesRead),
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
ac.EvalPermission(ac.ActionAlertingRoutesRead),
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
)) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Silences", SubTitle: "Stop notifications from one or more alerting rules", Id: "silences", Url: s.cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"})
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Time intervals",
SubTitle: "Configure time intervals for notification policies",
Id: "notification-config-time-intervals",
Url: s.cfg.AppSubURL + "/alerting/routes?tab=time_intervals",
Icon: "clock-nine",
})
}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts with active notifications", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
if len(notificationConfigChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Notification configuration",
SubTitle: "Configure how alerts are notified",
Id: "notification-config",
Url: s.cfg.AppSubURL + "/alerting/notifications",
Icon: "cog",
Children: notificationConfigChildren,
})
}
var insightsChildren []*navtree.NavLink
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
insightsChildren = append(insightsChildren, &navtree.NavLink{
Text: "System Insights",
SubTitle: "View system insights and analytics",
Id: "insights-system", Url: s.cfg.AppSubURL + "/alerting/insights",
Icon: "chart-line",
})
}
// Alert state history
//nolint:staticcheck // not yet migrated to OpenFeature
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "History",
insightsChildren = append(insightsChildren, &navtree.NavLink{
Text: "Alert state history",
SubTitle: "View a history of all alert events generated by your Grafana-managed alert rules. All alert events are displayed regardless of whether silences or mute timings are set.",
Id: "alerts-history",
Id: "insights-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
}
//nolint:staticcheck // not yet migrated to OpenFeature
if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) {
if len(insightsChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
Id: "alerts/recently-deleted",
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
Text: "Insights",
SubTitle: "Analytics and history for alerting",
Id: "insights",
Url: s.cfg.AppSubURL + "/alerting/insights",
Icon: "chart-line",
Children: insightsChildren,
})
}
if c.GetOrgRole() == org.RoleAdmin {
settingsChildren := []*navtree.NavLink{
{
Text: "Settings",
Id: "alerting-admin",
Url: s.cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
},
}
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
Text: "Settings",
SubTitle: "Alerting configuration and administration",
Id: "alerting-settings",
Url: s.cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
Children: settingsChildren,
})
}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert",
Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, IsCreateAction: true,
Text: "Create alert rule",
SubTitle: "Create an alert rule",
Id: "alert",
Icon: "plus",
Url: s.cfg.AppSubURL + "/alerting/new",
HideFromTabs: true,
IsCreateAction: true,
})
}
@@ -0,0 +1,158 @@
package navtreeimpl
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
// Test fixtures
func setupTestContext() *contextmodel.ReqContext {
httpReq, _ := http.NewRequest(http.MethodGet, "", nil)
return &contextmodel.ReqContext{
SignedInUser: &user.SignedInUser{
UserID: 1,
OrgID: 1,
OrgRole: org.RoleAdmin,
},
Context: &web.Context{Req: httpReq},
}
}
func setupTestService(permissions []ac.Permission, featureFlags ...string) ServiceImpl {
// Convert string slice to []any for WithFeatures
flags := make([]any, len(featureFlags))
for i, flag := range featureFlags {
flags[i] = flag
}
return ServiceImpl{
log: log.New("navtree"),
cfg: setting.NewCfg(),
accessControl: accesscontrolmock.New().WithPermissions(permissions),
features: featuremgmt.WithFeatures(flags...),
}
}
func fullPermissions() []ac.Permission {
return []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
{Action: ac.ActionAlertingNotificationsRead, Scope: "*"},
{Action: ac.ActionAlertingRoutesRead, Scope: "*"},
{Action: ac.ActionAlertingInstanceRead, Scope: "*"},
}
}
// Helper to find a nav link by ID
func findNavLink(navLink *navtree.NavLink, id string) *navtree.NavLink {
if navLink == nil {
return nil
}
if navLink.Id == id {
return navLink
}
for _, child := range navLink.Children {
if found := findNavLink(child, id); found != nil {
return found
}
}
return nil
}
// Helper to check if a nav link has a child with given ID
func hasChildWithId(parent *navtree.NavLink, childId string) bool {
if parent == nil {
return false
}
for _, child := range parent.Children {
if child.Id == childId {
return true
}
}
return false
}
func TestBuildAlertNavLinks(t *testing.T) {
reqCtx := setupTestContext()
allFeatureFlags := []string{"alertingTriage", "alertingCentralAlertHistory", "alertRuleRestore", "alertingRuleRecoverDeleted"}
service := setupTestService(fullPermissions(), allFeatureFlags...)
t.Run("Should have correct parent structure", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
require.NotEmpty(t, navLink.Children)
// Verify all parent items exist with children
parentIds := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
for _, parentId := range parentIds {
parent := findNavLink(navLink, parentId)
require.NotNil(t, parent, "Should have parent %s", parentId)
require.NotEmpty(t, parent.Children, "Parent %s should have children", parentId)
}
})
t.Run("Should have correct tabs under each parent", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Table-driven test for tab verification
tests := []struct {
parentId string
expectedTabs []string
}{
{"alert-rules", []string{"alert-rules-list", "alert-rules-recently-deleted"}},
{"notification-config", []string{"notification-config-contact-points", "notification-config-policies", "notification-config-templates", "notification-config-time-intervals"}},
{"insights", []string{"insights-system", "insights-history"}},
}
for _, tt := range tests {
parent := findNavLink(navLink, tt.parentId)
require.NotNil(t, parent, "Should have %s parent", tt.parentId)
for _, expectedTab := range tt.expectedTabs {
require.True(t, hasChildWithId(parent, expectedTab), "Parent %s should have tab %s", tt.parentId, expectedTab)
}
}
})
t.Run("Should respect permissions", func(t *testing.T) {
limitedPermissions := []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
}
limitedService := setupTestService(limitedPermissions)
navLink := limitedService.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Should not have notification-config without notification permissions
require.Nil(t, findNavLink(navLink, "notification-config"), "Should not have notification-config without permissions")
})
t.Run("Should exclude future items", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Verify future items are not present
futureIds := []string{
"alert-rules-recording-rules",
"alert-rules-evaluation-chains",
"insights-alert-optimizer",
"insights-notification-history",
}
for _, futureId := range futureIds {
require.Nil(t, findNavLink(navLink, futureId), "Should not have future item %s", futureId)
}
})
}
+2 -5
View File
@@ -10,7 +10,6 @@ import (
"testing"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
@@ -379,10 +378,8 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) {
err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{
ProviderType: setting.StaticProviderType,
StaticFlags: map[string]memprovider.InMemoryFlag{
featuremgmt.FlagPluginsAutoUpdate: {
Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue},
},
StaticFlags: map[string]bool{
featuremgmt.FlagPluginsAutoUpdate: flagValue,
},
})
require.NoError(t, err)
+5 -75
View File
@@ -1,20 +1,13 @@
package setting
import (
"encoding/json"
"math"
"strconv"
"gopkg.in/ini.v1"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/util"
)
// DefaultVariantName a placeholder name for config-based Feature Flags
const DefaultVariantName = "default"
// Deprecated: should use `featuremgmt.FeatureToggles`
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
section := iniFile.Section("feature_toggles")
@@ -22,27 +15,18 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
if err != nil {
return err
}
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
// nolint:staticcheck
cfg.IsFeatureToggleEnabled = func(key string) bool {
toggle, ok := toggles[key]
if !ok {
return false
}
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
return value && ok
}
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
return nil
}
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
featureToggles := make(map[string]bool, 10)
// parse the comma separated list in `enable`.
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
for _, feature := range util.SplitString(featuresTogglesStr) {
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
featureToggles[feature] = true
}
// read all other settings under [feature_toggles]. If a toggle is
@@ -52,7 +36,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
continue
}
b, err := ParseFlag(v.Name(), v.Value())
b, err := strconv.ParseBool(v.Value())
if err != nil {
return featureToggles, err
}
@@ -61,57 +45,3 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
}
return featureToggles, nil
}
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
var structure map[string]any
if integer, err := strconv.Atoi(value); err == nil {
return NewInMemoryFlag(name, integer), nil
}
if float, err := strconv.ParseFloat(value, 64); err == nil {
return NewInMemoryFlag(name, float), nil
}
if err := json.Unmarshal([]byte(value), &structure); err == nil {
return NewInMemoryFlag(name, structure), nil
}
if boolean, err := strconv.ParseBool(value); err == nil {
return NewInMemoryFlag(name, boolean), nil
}
return NewInMemoryFlag(name, value), nil
}
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
}
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
var res = map[string]string{}
for k, v := range m {
res[k] = serializeFlagValue(v)
}
return res
}
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
value := flag.Variants[flag.DefaultVariant]
switch castedValue := value.(type) {
case bool:
return strconv.FormatBool(castedValue)
case int64:
return strconv.FormatInt(castedValue, 10)
case float64:
// handle cases with a single or no zeros after the decimal point
if math.Trunc(castedValue) == castedValue {
return strconv.FormatFloat(castedValue, 'f', 1, 64)
}
return strconv.FormatFloat(castedValue, 'g', -1, 64)
case string:
return castedValue
default:
val, _ := json.Marshal(value)
return string(val)
}
}
+23 -54
View File
@@ -1,11 +1,9 @@
package setting
import (
"strconv"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
@@ -14,16 +12,17 @@ func TestFeatureToggles(t *testing.T) {
testCases := []struct {
name string
conf map[string]string
expectedToggles map[string]memprovider.InMemoryFlag
err error
expectedToggles map[string]bool
}{
{
name: "can parse feature toggles passed in the `enable` array",
conf: map[string]string{
"enable": "feature1,feature2",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
},
},
{
@@ -32,10 +31,10 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature3": "true",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
"feature3": NewInMemoryFlag("feature3", true),
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
"feature3": true,
},
},
{
@@ -44,26 +43,19 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature2": "false",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", false),
expectedToggles: map[string]bool{
"feature1": true,
"feature2": false,
},
},
{
name: "feature flags of different types are handled correctly",
name: "invalid boolean value should return syntax error",
conf: map[string]string{
"feature1": "1", "feature2": "1.0",
"feature3": `{"foo":"bar"}`, "feature4": "bar",
"feature5": "t", "feature6": "T",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", 1),
"feature2": NewInMemoryFlag("feature2", 1.0),
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
"feature4": NewInMemoryFlag("feature4", "bar"),
"feature5": NewInMemoryFlag("feature5", true),
"feature6": NewInMemoryFlag("feature6", true),
"enable": "feature1,feature2",
"feature2": "invalid",
},
expectedToggles: map[string]bool{},
err: strconv.ErrSyntax,
},
}
@@ -77,35 +69,12 @@ func TestFeatureToggles(t *testing.T) {
}
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
require.NoError(t, err)
require.ErrorIs(t, err, tc.err)
for k, v := range featureToggles {
toggle := tc.expectedToggles[k]
require.Equal(t, toggle, v, tc.name)
}
}
}
func TestFlagValueSerialization(t *testing.T) {
testCases := []memprovider.InMemoryFlag{
NewInMemoryFlag("int", 1),
NewInMemoryFlag("1.0f", 1.0),
NewInMemoryFlag("1.01f", 1.01),
NewInMemoryFlag("1.10f", 1.10),
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
NewInMemoryFlag("string", "bar"),
NewInMemoryFlag("true", true),
NewInMemoryFlag("false", false),
}
for _, tt := range testCases {
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
assert.NoError(t, err)
if diff := cmp.Diff(tt, deserialized); diff != "" {
t.Errorf("(-want, +got) = %v", diff)
if err == nil {
for k, v := range featureToggles {
require.Equal(t, tc.expectedToggles[k], v, tc.name)
}
}
}
}
@@ -81,12 +81,6 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac
kvbackend, db := newKvBackend(t.Context())
sqlbackend, _ := newSqlBackend(t.Context())
// Skip on SQLite due to concurrency limitations
if db.DriverName() == "sqlite3" {
t.Skip("Skipping concurrent operations stress test on SQLite")
}
tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db)
})
}
@@ -692,6 +686,11 @@ func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource
// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends
func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
// Skip on SQLite due to concurrency limitations
if db.DriverName() == "sqlite3" {
t.Skip("Skipping concurrent operations stress test on SQLite")
}
ctx := testutil.NewDefaultTestContext(t)
// Create storage servers from both backends
+1 -1
View File
@@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) {
"value": true,
"key":"`+flag+`",
"reason":"static provider evaluation result",
"variant":"default"}`, string(rsp.Body))
"variant":"enabled"}`, string(rsp.Body))
})
}
+1 -4
View File
@@ -15,10 +15,7 @@ import (
func TestMain(m *testing.M) {
// make sure we don't leak goroutines after tests in this package have
// finished, which means we haven't leaked contexts either
// (Except for goroutines running specific functions. If possible we should fix this.)
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"),
)
goleak.VerifyTestMain(m)
}
func TestTestContextFunc(t *testing.T) {
@@ -11,7 +11,6 @@ import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
import { getState } from 'app/store/store';
import { ThunkDispatch } from 'app/types/store';
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
@@ -20,26 +19,6 @@ import { refetchChildren } from '../../../../features/browse-dashboards/state/ac
import { handleError } from '../../../utils';
import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded';
const handleProvisioningFormError = (e: unknown, dispatch: ThunkDispatch, title: string) => {
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
const statusError: Status = e.error.data;
dispatch(notifyApp(createErrorNotification(title, new Error(statusError.message || 'Unknown error'))));
return;
}
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification(title)));
}
return;
}
}
handleError(e, dispatch, title);
};
export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
endpoints: {
listJob: {
@@ -58,17 +37,6 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
}),
onCacheEntryAdded: createOnCacheEntryAdded<RepositorySpec, RepositoryStatus>('repositories'),
},
listConnection: {
providesTags: (result) =>
result
? [
{ type: 'Connection', id: 'LIST' },
...result.items
.map((connection) => ({ type: 'Connection' as const, id: connection.metadata?.name }))
.filter(Boolean),
]
: [{ type: 'Connection', id: 'LIST' }],
},
deleteRepository: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
@@ -136,7 +104,34 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
try {
await queryFulfilled;
} catch (e) {
handleProvisioningFormError(e, dispatch, 'Error validating repository');
// Handle special cases first
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
// Handle Status error responses (Kubernetes style)
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
const statusError: Status = e.error.data;
dispatch(
notifyApp(
createErrorNotification(
'Error validating repository',
new Error(statusError.message || 'Unknown error')
)
)
);
return;
}
// Handle TestResults error responses with field errors
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
// Only show notification if there are errors that don't have a field, field errors are handled by the form
if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification('Error validating repository')));
}
return;
}
}
// For all other cases, use handleError
handleError(e, dispatch, 'Error validating repository');
}
},
},
@@ -245,70 +240,6 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
}
},
},
createConnection: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(t('provisioning.connection-form.alert-connection-saved', 'Connection saved'))
)
);
} catch (e) {
handleProvisioningFormError(
e,
dispatch,
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
);
}
},
},
replaceConnection: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
t('provisioning.connection-form.alert-connection-updated', 'Connection updated')
)
)
);
} catch (e) {
handleProvisioningFormError(
e,
dispatch,
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
);
}
},
},
deleteConnection: {
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Connection', id: 'LIST' }]),
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
t('provisioning.connection-form.alert-connection-deleted', 'Connection deleted')
)
)
);
} catch (e) {
if (e instanceof Error) {
dispatch(
notifyApp(
createErrorNotification(
t('provisioning.connection-form.error-delete-connection', 'Failed to delete connection'),
e
)
)
);
}
}
},
},
},
});
@@ -37,9 +37,24 @@ export const MegaMenu = memo(
const pinnedItems = usePinnedItems();
// Remove profile + help from tree
// For Alerting navigation, flatten the sidebar to show only top-level items (hide nested children/tabs)
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
.map((item) => {
const enriched = enrichWithInteractionTracking(item, state.megaMenuDocked);
// If this is Alerting section, flatten children for sidebar display
// Children are still available in navIndex for breadcrumbs and page navigation
if (item.id === 'alerting' && enriched.children) {
return {
...enriched,
children: enriched.children.map((child) => ({
...child,
children: undefined, // Remove nested children from sidebar, but keep them for page navigation
})),
};
}
return enriched;
});
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
if (bookmarksItem) {
@@ -35,11 +35,18 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
if (shouldAddCrumb) {
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
// Add tab to breadcrumbs if it's not the first active child
if (activeChildIndex > 0) {
// Add active tab to breadcrumbs if it exists and its URL is different from the node's URL
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
if (activeChildIndex >= 0) {
const activeChild = node.children?.[activeChildIndex];
if (activeChild) {
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
// Only add the active child if its URL doesn't match the node's URL
// This prevents duplication when the pageNav is the active tab
const nodeUrl = node.url?.split('?')[0] ?? '';
const childUrl = activeChild.url?.split('?')[0] ?? '';
if (nodeUrl !== childUrl) {
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
}
}
}
crumbs.unshift({ text: node.text, href: node.url ?? '' });
@@ -4,8 +4,8 @@ import * as React from 'react';
import SplitPane, { Split } from 'react-split-pane';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getDragStyles } from '@grafana/ui';
import { config } from 'app/core/config';
interface Props {
splitOrientation?: Split;
+1
View File
@@ -1,5 +1,6 @@
import { PluginState } from '@grafana/data';
import { config, GrafanaBootConfig } from '@grafana/runtime';
export { config, type GrafanaBootConfig as Settings };
let grafanaConfig: GrafanaBootConfig = config;
@@ -2,7 +2,7 @@ import deepEqual from 'fast-deep-equal';
import memoize from 'micro-memoize';
import { getLanguage } from '@grafana/i18n/internal';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual });
+2 -1
View File
@@ -1,7 +1,8 @@
import { getThemeById } from '@grafana/data/internal';
import { config, ThemeChangedEvent } from '@grafana/runtime';
import { ThemeChangedEvent } from '@grafana/runtime';
import { appEvents } from '../app_events';
import { config } from '../config';
import { contextSrv } from '../services/context_srv';
import { PreferencesService } from './PreferencesService';
+19 -1
View File
@@ -1,7 +1,7 @@
import { Navigate } from 'react-router-dom-v5-compat';
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types';
import { AccessControlAction } from 'app/types/accessControl';
@@ -56,6 +56,17 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
),
},
{
path: '/alerting/time-intervals',
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
...PERMISSIONS_TIME_INTERVALS_READ,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "TimeIntervalsPage" */ 'app/features/alerting/unified/TimeIntervalsPage')
),
},
{
path: '/alerting/routes/mute-timing/new',
roles: evaluateAccess([
@@ -212,6 +223,13 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
),
},
{
path: '/alerting/insights',
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "InsightsPage" */ 'app/features/alerting/unified/insights/InsightsPage')
),
},
{
path: '/alerting/recently-deleted/',
roles: () => ['Admin'],
@@ -1,4 +1,4 @@
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs'];
@@ -14,6 +14,7 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { fetchAlertGroupsAction } from './state/actions';
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
@@ -113,8 +114,9 @@ const AlertGroups = () => {
};
function AlertGroupsPage() {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertmanagerPageWrapper navId="groups" accessType="instance">
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
<AlertGroups />
</AlertmanagerPageWrapper>
);
@@ -140,6 +140,35 @@ const getRootRoute = async () => {
};
describe('NotificationPolicies', () => {
beforeEach(() => {
setupDataSources(dataSources.am);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
...PERMISSIONS_NOTIFICATION_POLICIES,
]);
});
it('shows only notification policies without internal tabs', async () => {
renderNotificationPolicies();
// Should show notification policies directly
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
it('does not show time intervals tab', async () => {
renderNotificationPolicies();
// Should show notification policies
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not show time intervals tab
expect(screen.queryByText(/time intervals/i)).not.toBeInTheDocument();
});
// combobox hack :/
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
@@ -1,115 +1,29 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { NotificationPoliciesList } from 'app/features/alerting/unified/components/notification-policies/NotificationPoliciesList';
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
enum ActiveTab {
NotificationPolicies = 'notification_policies',
TimeIntervals = 'time_intervals',
}
const NotificationPoliciesTabs = () => {
const styles = useStyles2(getStyles);
// Alertmanager logic and data hooks
const NotificationPoliciesContent = () => {
const { selectedAlertmanager = '' } = useAlertmanager();
const [policiesSupported, canSeePoliciesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationPolicyTree);
const [timingsSupported, canSeeTimingsTab] = useAlertmanagerAbility(AlertmanagerAction.ViewTimeInterval);
const availableTabs = [
canSeePoliciesTab && ActiveTab.NotificationPolicies,
canSeeTimingsTab && ActiveTab.TimeIntervals,
].filter((tab) => !!tab);
const { data: muteTimings = [] } = useMuteTimings({
alertmanager: selectedAlertmanager,
skip: !canSeeTimingsTab,
});
// Tab state management
const [queryParams, setQueryParams] = useQueryParams();
const { tab } = getActiveTabFromUrl(queryParams, availableTabs[0]);
const [activeTab, setActiveTab] = useState<ActiveTab>(tab);
const muteTimingsTabActive = activeTab === ActiveTab.TimeIntervals;
const policyTreeTabActive = activeTab === ActiveTab.NotificationPolicies;
const numberOfMuteTimings = muteTimings.length;
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
<TabsBar>
{policiesSupported && canSeePoliciesTab && (
<Tab
label={t('alerting.notification-policies-tabs.label-notification-policies', 'Notification Policies')}
active={policyTreeTabActive}
onChangeTab={() => {
setActiveTab(ActiveTab.NotificationPolicies);
setQueryParams({ tab: ActiveTab.NotificationPolicies });
}}
/>
)}
{timingsSupported && canSeeTimingsTab && (
<Tab
label={t('alerting.notification-policies-tabs.label-time-intervals', 'Time intervals')}
active={muteTimingsTabActive}
counter={numberOfMuteTimings}
onChangeTab={() => {
setActiveTab(ActiveTab.TimeIntervals);
setQueryParams({ tab: ActiveTab.TimeIntervals });
}}
/>
)}
</TabsBar>
<TabContent className={styles.tabContent}>
{policyTreeTabActive && <NotificationPoliciesList />}
{muteTimingsTabActive && <TimeIntervalsTable />}
</TabContent>
<NotificationPoliciesList />
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
tabContent: css({
marginTop: theme.spacing(2),
}),
});
interface QueryParamValues {
tab: ActiveTab;
}
function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): QueryParamValues {
let tab = defaultTab;
if (queryParams.tab === ActiveTab.NotificationPolicies) {
tab = ActiveTab.NotificationPolicies;
}
if (queryParams.tab === ActiveTab.TimeIntervals) {
tab = ActiveTab.TimeIntervals;
}
return {
tab,
};
}
function NotificationPoliciesPage() {
const { navId, pageNav } = useNotificationConfigNav();
// Show only notification policies (no internal tabs)
// Time intervals are accessible via the sidebar navigation
return (
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
<NotificationPoliciesTabs />
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<NotificationPoliciesContent />
</AlertmanagerPageWrapper>
);
}
@@ -1,13 +1,52 @@
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Trans } from '@grafana/i18n';
import { LinkButton, Stack, Text } from '@grafana/ui';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
import { NotificationTemplates } from './components/contact-points/NotificationTemplates';
import { AlertmanagerAction, useAlertmanagerAbility } from './hooks/useAbilities';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { withPageErrorBoundary } from './withPageErrorBoundary';
function NotificationTemplates() {
const TemplatesList = () => {
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
Create notification templates to customize your notifications.
</Trans>
</Text>
{createTemplateSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/templates/new"
disabled={!createTemplateAllowed}
>
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
Add notification template group
</Trans>
</LinkButton>
)}
</Stack>
<NotificationTemplates />
</>
);
};
function NotificationTemplatesRoutes() {
return (
<Routes>
<Route path="" element={<TemplatesList />} />
<Route path="new" element={<NewMessageTemplate />} />
<Route path=":name/edit" element={<EditMessageTemplate />} />
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
@@ -15,4 +54,14 @@ function NotificationTemplates() {
);
}
export default withPageErrorBoundary(NotificationTemplates);
function NotificationTemplatesPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<NotificationTemplatesRoutes />
</AlertmanagerPageWrapper>
);
}
export default withPageErrorBoundary(NotificationTemplatesPage);
@@ -0,0 +1,65 @@
import { render, screen } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types/accessControl';
import TimeIntervalsPage from './TimeIntervalsPage';
import { defaultConfig } from './components/mute-timings/mocks';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource } from './mocks';
import { setTimeIntervalsListEmpty } from './mocks/server/configure';
import { setAlertmanagerConfig } from './mocks/server/entities/alertmanagers';
import { setupDataSources } from './testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
setupMswServer();
const alertManager = mockDataSource({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
});
describe('TimeIntervalsPage', () => {
beforeEach(() => {
setupDataSources(alertManager);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, defaultConfig);
setTimeIntervalsListEmpty(); // Mock empty time intervals list so component renders
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingTimeIntervalsRead,
]);
});
it('renders time intervals table', async () => {
const mockNavIndex = {
'notification-config': {
id: 'notification-config',
text: 'Notification configuration',
url: '/alerting/notifications',
},
'notification-config-time-intervals': {
id: 'notification-config-time-intervals',
text: 'Time intervals',
url: '/alerting/time-intervals',
},
};
const store = configureStore({
navIndex: mockNavIndex,
});
render(<TimeIntervalsPage />, {
store,
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
// Should show time intervals content
// When empty, it shows "You haven't created any time intervals yet"
// When loading, it shows "Loading time intervals..."
// When error, it shows "Error loading time intervals"
// All contain "time intervals" - use getAllByText since there are multiple matches (tab, description, empty state)
const timeIntervalsTexts = await screen.findAllByText(/time intervals/i, {}, { timeout: 5000 });
expect(timeIntervalsTexts.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,31 @@
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
// Content component that uses AlertmanagerContext
// This must be rendered within AlertmanagerPageWrapper
function TimeIntervalsPageContent() {
const { selectedAlertmanager } = useAlertmanager();
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<TimeIntervalsTable />
</>
);
}
function TimeIntervalsPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<TimeIntervalsPageContent />
</AlertmanagerPageWrapper>
);
}
export default withPageErrorBoundary(TimeIntervalsPage);
@@ -170,6 +170,26 @@ describe('contact points', () => {
});
});
test('shows only contact points without internal tabs', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points directly
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
test('does not show templates tab', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not show templates tab
expect(screen.queryByText(/notification templates/i)).not.toBeInTheDocument();
});
describe('templates tab', () => {
it('does not show a warning for a "misconfigured" template', async () => {
renderWithProvider(
@@ -258,14 +278,6 @@ describe('contact points', () => {
// there should be view buttons though - one for provisioned, and one for the un-editable contact point
const viewButtons = screen.getAllByRole('link', { name: /^view$/i });
expect(viewButtons).toHaveLength(2);
// check buttons in Notification Templates
const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' });
await user.click(notificationTemplatesTab);
expect(screen.getByRole('link', { name: 'Add notification template group' })).toHaveAttribute(
'aria-disabled',
'true'
);
});
it('allows deleting when not disabled', async () => {
@@ -470,11 +482,6 @@ describe('contact points', () => {
const viewButton = screen.getByRole('link', { name: /^view$/i });
expect(viewButton).toBeInTheDocument();
expect(viewButton).toBeEnabled();
// check buttons in Notification Templates
const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' });
await user.click(notificationTemplatesTab);
expect(screen.queryByRole('link', { name: 'Add notification template group' })).not.toBeInTheDocument();
});
});
@@ -1,19 +1,5 @@
import { useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
import {
Alert,
Button,
EmptyState,
LinkButton,
LoadingPlaceholder,
Pagination,
Stack,
Tab,
TabContent,
TabsBar,
Text,
} from '@grafana/ui';
import { Alert, Button, EmptyState, LinkButton, LoadingPlaceholder, Pagination, Stack } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
@@ -22,6 +8,7 @@ import { AccessControlAction } from 'app/types/accessControl';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { usePagination } from '../../hooks/usePagination';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useNotificationConfigNav } from '../../navigation/useNotificationConfigNav';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { isExtraConfig } from '../../utils/alertmanager/extraConfigs';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@@ -30,7 +17,6 @@ import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from '../GrafanaAlertmanagerWarning';
import { ContactPoint } from './ContactPoint';
import { NotificationTemplates } from './NotificationTemplates';
import { ContactPointsFilter } from './components/ContactPointsFilter';
import { GlobalConfigAlert } from './components/GlobalConfigAlert';
import { useContactPointsWithStatus } from './useContactPoints';
@@ -99,7 +85,7 @@ const ContactPointsTab = () => {
}
return (
<>
<Stack direction="column" gap={1}>
{/* TODO we can add some additional info here with a ToggleTip */}
<Stack direction="row" alignItems="end" justifyContent="space-between">
<ContactPointsFilter />
@@ -148,109 +134,19 @@ const ContactPointsTab = () => {
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
)}
{ExportDrawer}
</>
</Stack>
);
};
const NotificationTemplatesTab = () => {
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
Create notification templates to customize your notifications.
</Trans>
</Text>
{createTemplateSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/templates/new"
disabled={!createTemplateAllowed}
>
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
Add notification template group
</Trans>
</LinkButton>
)}
</Stack>
<NotificationTemplates />
</>
);
};
const useTabQueryParam = (defaultTab: ActiveTab) => {
const [queryParams, setQueryParams] = useURLSearchParams();
const param = useMemo(() => {
const queryParam = queryParams.get('tab');
if (!queryParam || !Object.values(ActiveTab).map(String).includes(queryParam)) {
return defaultTab;
}
return queryParam || defaultTab;
}, [defaultTab, queryParams]);
const setParam = (tab: ActiveTab) => setQueryParams({ tab });
return [param, setParam] as const;
};
export const ContactPointsPageContents = () => {
const { selectedAlertmanager } = useAlertmanager();
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
const showContactPointsTab = canViewContactPoints || canCreateContactPoints;
// Depending on permissions, user may not have access to all tabs,
// but we can default to picking the first one that they definitely _do_ have access to
const defaultTab = [
showContactPointsTab && ActiveTab.ContactPoints,
showTemplatesTab && ActiveTab.NotificationTemplates,
].filter((tab) => !!tab)[0];
const [activeTab, setActiveTab] = useTabQueryParam(defaultTab);
const { contactPoints } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager!,
});
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
// Show only contact points (no internal tabs)
// Templates are accessible via the sidebar navigation
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<Stack direction="column">
<TabsBar>
{showContactPointsTab && (
<Tab
label={t('alerting.contact-points-page-contents.label-contact-points', 'Contact Points')}
active={showingContactPoints}
counter={contactPoints.length}
onChangeTab={() => setActiveTab(ActiveTab.ContactPoints)}
/>
)}
{showTemplatesTab && (
<Tab
label={t('alerting.contact-points-page-contents.label-notification-templates', 'Notification Templates')}
active={showNotificationTemplates}
onChangeTab={() => setActiveTab(ActiveTab.NotificationTemplates)}
/>
)}
</TabsBar>
<TabContent>
<Stack direction="column">
{showingContactPoints && <ContactPointsTab />}
{showNotificationTemplates && <NotificationTemplatesTab />}
</Stack>
</TabContent>
</Stack>
<ContactPointsTab />
</>
);
};
@@ -282,8 +178,9 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE
};
function ContactPointsPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<ContactPointsPageContents />
</AlertmanagerPageWrapper>
);
@@ -8,8 +8,8 @@ import {
ThresholdsMode,
isTimeSeriesFrames,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GraphThresholdsStyleMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';
@@ -1,11 +1,13 @@
import { useInsightsNav } from '../../../navigation/useInsightsNav';
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
function HistoryPage() {
const { navId, pageNav } = useInsightsNav();
return (
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
<AlertingPageWrapper navId={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
<CentralAlertHistoryScene />
</AlertingPageWrapper>
);
@@ -3,6 +3,7 @@ import { Alert } from '@grafana/ui';
import { alertRuleApi } from '../../../api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
import { useAlertRulesNav } from '../../../navigation/useAlertRulesNav';
import { stringifyErrorLike } from '../../../utils/misc';
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
@@ -18,9 +19,10 @@ function DeletedrulesPage() {
rulerConfig: GRAFANA_RULER_CONFIG,
filter: {}, // todo: add filters, and limit?????
});
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
<>
{error && (
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
@@ -1,23 +1,24 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { t } from '@grafana/i18n';
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { isLocalDevEnv } from '../utils/misc';
import { withPageErrorBoundary } from '../withPageErrorBoundary';
import GettingStarted, { WelcomeHeader } from './GettingStarted';
import IRMCard from './IRMCard';
import { getInsightsScenes, insightsIsAvailable } from './Insights';
import { getInsightsScenes } from './Insights';
import { PluginIntegrations } from './PluginIntegrations';
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
function Home() {
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
// Insights tab is not shown on Home page - Insights is available via the sidebar menu instead
const insightsEnabled = false;
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
const insightsScene = getInsightsScenes();
// Memoize the scene so it's only created once and properly initialized
const insightsScene = useMemo(() => getInsightsScenes(), []);
return (
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
@@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { getInsightsScenes, insightsIsAvailable } from '../home/Insights';
import { useInsightsNav } from '../navigation/useInsightsNav';
import { isLocalDevEnv } from '../utils/misc';
import { withPageErrorBoundary } from '../withPageErrorBoundary';
function InsightsPage() {
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
const { navId, pageNav } = useInsightsNav();
// Memoize the scene so it's only created once and properly initialized
const insightsScene = useMemo(() => getInsightsScenes(), []);
if (!insightsEnabled) {
return (
<AlertingPageWrapper
navId={navId || 'insights'}
pageNav={pageNav}
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
>
<div>
<Trans i18nKey="alerting.insights.not-available">
Insights are not available. Please configure the required data sources.
</Trans>
</div>
</AlertingPageWrapper>
);
}
return (
<AlertingPageWrapper
navId={navId || 'insights'}
pageNav={pageNav}
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
>
<insightsScene.Component model={insightsScene} />
</AlertingPageWrapper>
);
}
export default withPageErrorBoundary(InsightsPage);
@@ -0,0 +1,134 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { useAlertActivityNav } from './useAlertActivityNav';
describe('useAlertActivityNav', () => {
const mockNavIndex = {
'alert-activity': {
id: 'alert-activity',
text: 'Alert activity',
url: '/alerting/alerts',
},
'alert-activity-alerts': {
id: 'alert-activity-alerts',
text: 'Alerts',
url: '/alerting/alerts',
},
'alert-activity-groups': {
id: 'alert-activity-groups',
text: 'Active notifications',
url: '/alerting/groups',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav for Alerts tab', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should return navigation with pageNav for Active notifications tab', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should set active tab based on current path', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const activeNotificationsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-groups');
expect(activeNotificationsTab?.active).toBe(true);
// eslint-disable-next-line testing-library/no-node-access
const alertsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-alerts');
expect(alertsTab?.active).toBe(false);
});
it('should filter tabs based on permissions', () => {
const limitedNavIndex = {
'alert-activity': mockNavIndex['alert-activity'],
'alert-activity-alerts': mockNavIndex['alert-activity-alerts'],
// Missing 'alert-activity-groups' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('alert-activity-alerts');
});
it('should return undefined when alert-activity nav is missing', () => {
const store = configureStore({
navIndex: {},
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBeUndefined();
expect(result.current.pageNav).toBeUndefined();
});
});
@@ -0,0 +1,57 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useAlertActivityNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const alertActivityNav = navIndex['alert-activity'];
if (!alertActivityNav) {
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-activity-alerts',
text: t('alerting.navigation.alerts', 'Alerts'),
url: '/alerting/alerts',
active: location.pathname === '/alerting/alerts',
icon: 'bell',
parentItem: alertActivityNav,
},
{
id: 'alert-activity-groups',
text: t('alerting.navigation.active-notifications', 'Active notifications'),
url: '/alerting/groups',
active: location.pathname === '/alerting/groups',
icon: 'layer-group',
parentItem: alertActivityNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav structure following the same pattern as useNotificationConfigNav
// Keep "Alert Activity" as the pageNav (not the active tab) so the title and subtitle stay consistent
// The tabs are children, and the breadcrumb utility will add the active tab to breadcrumbs
// (including the first tab, after our fix to the breadcrumb utility)
const pageNav: NavModelItem = {
...alertActivityNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-activity',
pageNav,
};
}
@@ -0,0 +1,95 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { useAlertRulesNav } from './useAlertRulesNav';
describe('useAlertRulesNav', () => {
const mockNavIndex = {
'alert-rules': {
id: 'alert-rules',
text: 'Alert rules',
url: '/alerting/list',
icon: 'list-ul' as const,
},
'alert-rules-list': {
id: 'alert-rules-list',
text: 'Alert rules',
url: '/alerting/list',
},
'alert-rules-recently-deleted': {
id: 'alert-rules-recently-deleted',
text: 'Recently deleted',
url: '/alerting/recently-deleted',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/list'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
expect(result.current.navId).toBe('alert-rules');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBeGreaterThan(0);
});
it('should filter tabs based on permissions', () => {
const limitedNavIndex = {
'alert-rules': mockNavIndex['alert-rules'],
'alert-rules-list': mockNavIndex['alert-rules-list'],
// Missing 'alert-rules-recently-deleted' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/list'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('alert-rules-list');
});
it('should set active tab based on current path', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/recently-deleted'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const recentlyDeletedTab = result.current.pageNav?.children?.find(
(tab) => tab.id === 'alert-rules-recently-deleted'
);
expect(recentlyDeletedTab?.active).toBe(true);
});
});
@@ -0,0 +1,54 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useAlertRulesNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const alertRulesNav = navIndex['alert-rules'];
if (!alertRulesNav) {
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-rules-list',
text: t('alerting.navigation.alert-rules', 'Alert rules'),
url: '/alerting/list',
active: location.pathname === '/alerting/list',
icon: 'list-ul',
parentItem: alertRulesNav,
},
{
id: 'alert-rules-recently-deleted',
text: t('alerting.navigation.recently-deleted', 'Recently deleted'),
url: '/alerting/recently-deleted',
active: location.pathname === '/alerting/recently-deleted',
icon: 'trash-alt',
parentItem: alertRulesNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Alert rules page with tabs as children
const pageNav: NavModelItem = {
...alertRulesNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-rules',
pageNav,
};
}
@@ -0,0 +1,90 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { useInsightsNav } from './useInsightsNav';
describe('useInsightsNav', () => {
const mockNavIndex = {
insights: {
id: 'insights',
text: 'Insights',
url: '/alerting/insights',
},
'insights-system': {
id: 'insights-system',
text: 'System Insights',
url: '/alerting/insights',
},
'insights-history': {
id: 'insights-history',
text: 'Alert state history',
url: '/alerting/history',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/insights'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
expect(result.current.navId).toBe('insights');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
});
it('should set active tab based on current path', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/history'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const historyTab = result.current.pageNav?.children?.find((tab) => tab.id === 'insights-history');
expect(historyTab?.active).toBe(true);
});
it('should filter tabs based on permissions', () => {
const limitedNavIndex = {
insights: mockNavIndex.insights,
'insights-system': mockNavIndex['insights-system'],
// Missing 'insights-history' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/insights'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('insights-system');
});
});
@@ -0,0 +1,54 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useInsightsNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const insightsNav = navIndex.insights;
if (!insightsNav) {
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'insights-system',
text: t('alerting.navigation.system-insights', 'System Insights'),
url: '/alerting/insights',
active: location.pathname === '/alerting/insights',
icon: 'chart-line',
parentItem: insightsNav,
},
{
id: 'insights-history',
text: t('alerting.navigation.alert-state-history', 'Alert state history'),
url: '/alerting/history',
active: location.pathname === '/alerting/history',
icon: 'history',
parentItem: insightsNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Insights page with tabs as children
const pageNav: NavModelItem = {
...insightsNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'insights',
pageNav,
};
}
@@ -0,0 +1,102 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { useNotificationConfigNav } from './useNotificationConfigNav';
describe('useNotificationConfigNav', () => {
const mockNavIndex = {
'notification-config': {
id: 'notification-config',
text: 'Notification configuration',
url: '/alerting/notifications',
},
'notification-config-contact-points': {
id: 'notification-config-contact-points',
text: 'Contact points',
url: '/alerting/notifications',
},
'notification-config-policies': {
id: 'notification-config-policies',
text: 'Notification policies',
url: '/alerting/routes',
},
'notification-config-templates': {
id: 'notification-config-templates',
text: 'Notification templates',
url: '/alerting/notifications/templates',
},
'notification-config-time-intervals': {
id: 'notification-config-time-intervals',
text: 'Time intervals',
url: '/alerting/time-intervals',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/notifications'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
expect(result.current.navId).toBe('notification-config');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
});
it('should detect time intervals tab from path', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const timeIntervalsTab = result.current.pageNav?.children?.find(
(tab) => tab.id === 'notification-config-time-intervals'
);
expect(timeIntervalsTab?.active).toBe(true);
});
it('should filter tabs based on permissions', () => {
const limitedNavIndex = {
'notification-config': mockNavIndex['notification-config'],
'notification-config-contact-points': mockNavIndex['notification-config-contact-points'],
// Missing other tabs - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/notifications'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('notification-config-contact-points');
});
});
@@ -0,0 +1,86 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useNotificationConfigNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const notificationConfigNav = navIndex['notification-config'];
if (!notificationConfigNav) {
// Fallback to legacy navIds
if (location.pathname.includes('/alerting/notifications/templates')) {
return {
navId: 'receivers',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/routes') {
return {
navId: 'am-routes',
pageNav: undefined,
};
}
return {
navId: 'receivers',
pageNav: undefined,
};
}
// Check if we're on the time intervals page
const isTimeIntervalsTab = location.pathname === '/alerting/time-intervals';
// All available tabs
const allTabs = [
{
id: 'notification-config-contact-points',
text: t('alerting.navigation.contact-points', 'Contact points'),
url: '/alerting/notifications',
active: location.pathname === '/alerting/notifications' && !location.pathname.includes('/templates'),
icon: 'comment-alt-share',
parentItem: notificationConfigNav,
},
{
id: 'notification-config-policies',
text: t('alerting.navigation.notification-policies', 'Notification policies'),
url: '/alerting/routes',
active: location.pathname === '/alerting/routes' && !isTimeIntervalsTab,
icon: 'sitemap',
parentItem: notificationConfigNav,
},
{
id: 'notification-config-templates',
text: t('alerting.navigation.notification-templates', 'Notification templates'),
url: '/alerting/notifications/templates',
active: location.pathname.includes('/alerting/notifications/templates'),
icon: 'file-alt',
parentItem: notificationConfigNav,
},
{
id: 'notification-config-time-intervals',
text: t('alerting.navigation.time-intervals', 'Time intervals'),
url: '/alerting/time-intervals',
active: isTimeIntervalsTab,
icon: 'clock-nine',
parentItem: notificationConfigNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Notification configuration page with tabs as children
const pageNav: NavModelItem = {
...notificationConfigNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'notification-config',
pageNav,
};
}
@@ -20,6 +20,7 @@ import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
@@ -115,11 +116,14 @@ const RuleListV1 = () => {
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
const { navId, pageNav } = useAlertRulesNav();
return (
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper
navId="alert-list"
navId={navId}
pageNav={pageNav}
isLoading={false}
renderTitle={(title) => <RuleListPageTitle title={title} />}
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
@@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
import { useRulesFilter } from '../hooks/useFilteredRules';
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
import { getRulesDataSources } from '../utils/datasource';
import { FilterView } from './FilterView';
@@ -123,10 +124,12 @@ export function RuleListActions() {
export default function RuleListPage() {
const { isApplying } = useApplyDefaultSearch();
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper
navId="alert-list"
navId={navId}
pageNav={pageNav}
renderTitle={(title) => <RuleListPageTitle title={title} />}
isLoading={isApplying}
actions={<RuleListActions />}
@@ -1,23 +1,16 @@
import { t } from '@grafana/i18n';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { useAlertActivityNav } from '../navigation/useAlertActivityNav';
import { TriageScene, triageScene } from './scene/TriageScene';
export const TriagePage = () => {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertingPageWrapper
navId="alert-alerts"
subTitle={t(
'alerting.pages.triage.subtitle',
'See what is currently alerting and explore historical data to investigate current or past issues.'
)}
pageNav={{
text: t('alerting.pages.triage.title', 'Alerts'),
}}
>
<AlertingPageWrapper navId={navId || 'alert-alerts'} pageNav={pageNav}>
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<TriageScene key={triageScene.state.key} />
</UrlSyncContextProvider>
@@ -18,7 +18,7 @@ import {
standardTransformers,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
export const standardAnnotationSupport: AnnotationSupport = {
/**
@@ -3,9 +3,10 @@ import { connect, ConnectedProps } from 'react-redux';
import { GrafanaEdition } from '@grafana/data/internal';
import { Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import { Grid, TextLink, ToolbarButton } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { config } from 'app/core/config';
import { StoreState } from 'app/types/store';
import { isOpenSourceBuildOrUnlicenced } from '../admin/EnterpriseAuthFeaturesCard';
+1 -1
View File
@@ -2,8 +2,8 @@ import { ComponentType } from 'react';
import { DataLink, RegistryItem, Action } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/internal';
import { config } from '@grafana/runtime';
import { ColorDimensionConfig, ScaleDimensionConfig, DirectionDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
import { BackgroundConfig, Constraint, LineConfig, Placement } from 'app/plugins/panel/canvas/panelcfg.gen';
import { LineStyleConfig } from '../../plugins/panel/canvas/editor/LineStyleEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -14,10 +14,10 @@ import {
ActionType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { config } from 'app/core/config';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions/context';
import {
+2 -1
View File
@@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import Selecto from 'selecto';
import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
@@ -17,6 +17,7 @@ import {
DirectionDimensionConfig,
} from '@grafana/schema';
import { Portal } from '@grafana/ui';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import {
getColorDimensionFromData,
@@ -2,7 +2,7 @@ import InfiniteViewer from 'infinite-viewer';
import Moveable from 'moveable';
import Selecto from 'selecto';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors';
import {
CONNECTION_VERTEX_ID,
@@ -18,7 +18,6 @@ import {
NewObjectAddedToCanvasEvent,
ObjectRemovedFromCanvasEvent,
ObjectsReorderedOnCanvasEvent,
RepeatsUpdatedEvent,
} from './shared';
export interface DashboardEditPaneState extends SceneObjectState {
@@ -88,12 +87,6 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
})
);
this._subs.add(
dashboard.subscribeToEvent(RepeatsUpdatedEvent, () => {
this.forceRender();
})
);
if (this.panelEditAction) {
this.performPanelEditAction(this.panelEditAction);
this.panelEditAction = undefined;
@@ -57,10 +57,12 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const outlineChildren = editableElement.getOutlineChildren?.(isEditing) ?? [];
const visibleChildren = isEditing
? outlineChildren
: outlineChildren.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
const visibleChildren = useMemo(() => {
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
return isEditing
? children
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
}, [editableElement, isEditing]);
const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -256,6 +258,7 @@ function getStyles(theme: GrafanaTheme2) {
}),
nodeButtonClone: css({
color: theme.colors.text.secondary,
cursor: 'not-allowed',
}),
outlineInput: css({
border: `1px solid ${theme.components.input.borderColor}`,
@@ -84,10 +84,6 @@ export class ConditionalRenderingChangedEvent extends BusEventWithPayload<SceneO
static type = 'conditional-rendering-changed';
}
export class RepeatsUpdatedEvent extends BusEventWithPayload<SceneObject> {
static type = 'repeats-updated';
}
export interface DashboardEditActionEventPayload {
removedObject?: SceneObject;
addedObject?: SceneObject;
@@ -14,7 +14,7 @@ import {
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -147,7 +147,6 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
this.setState({ repeatedPanels, repeatedConditionalRendering });
this._prevRepeatValues = values;
this.publishEvent(new RepeatsUpdatedEvent(this), true);
}
public getPanelCount() {
@@ -17,7 +17,7 @@ import {
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -219,7 +219,6 @@ export class DashboardGridItem
}
this._prevRepeatValues = values;
this.publishEvent(new RepeatsUpdatedEvent(this), true);
}
public handleVariableName() {
@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { config } from '../../../../core/config';
import { grantUserPermissions } from '../../../alerting/unified/mocks';
import { DashboardScene, DashboardSceneState } from '../../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
@@ -1,8 +1,8 @@
import * as React from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { SharePublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard';
import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
@@ -4,7 +4,7 @@ const mockPushMeasurement = jest.fn();
import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor';
jest.mock('@grafana/runtime', () => ({
jest.mock('app/core/config', () => ({
config: {
grafanaJavascriptAgent: {
enabled: true,
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { faro } from '@grafana/faro-web-sdk';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { PanelLogEvents } from 'app/core/log_events';
interface Props {
@@ -12,7 +12,7 @@ jest.mock('@grafana/faro-web-sdk', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
jest.mock('app/core/config', () => ({
config: {
grafanaJavascriptAgent: {
enabled: true,
@@ -1,6 +1,6 @@
import { FieldConfigSource } from '@grafana/data';
import { faro } from '@grafana/faro-web-sdk';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events';
interface PanelLogInfo {
+1 -1
View File
@@ -1,7 +1,7 @@
import { config } from '@grafana/runtime';
import { DashboardRoutes } from 'app/types/dashboard';
import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport';
import { config } from '../../core/config';
import { RouteDescriptor } from '../../core/navigation/types';
export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
@@ -13,9 +13,10 @@ import {
dateTimeForTimeZone,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { appEvents } from 'app/core/app_events';
import { config } from 'app/core/config';
import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv';
import {
getCopiedTimeRange,
@@ -2,8 +2,8 @@ import { each, map } from 'lodash';
import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { config } from '@grafana/runtime';
import { FieldConfigSource } from '@grafana/schema';
import { config } from 'app/core/config';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
@@ -6,7 +6,7 @@ import {
LoadingState,
PanelData,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker';
import { getTimeSrv } from '../services/TimeSrv';
@@ -6,8 +6,9 @@ import { useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { config, getBackendSrv } from '@grafana/runtime';
import { getBackendSrv } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { MediaType, PickerTabType, ResourceFolderName } from '../types';
@@ -3,9 +3,10 @@ import { memo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data';
import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { StoreState } from 'app/types/store';
@@ -13,9 +13,10 @@ import {
EventBusSrv,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, getTemplateSrv, PanelRenderer } from '@grafana/runtime';
import { getTemplateSrv, PanelRenderer } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { AdHocFilterItem, PanelChrome, withTheme2, Themeable2, PanelContextProvider } from '@grafana/ui';
import { config } from 'app/core/config';
import {
hasDeprecatedParentRowIndex,
migrateFromParentRowIndexToNestedFrames,
@@ -22,7 +22,7 @@ import {
PluginExtensionPoints,
PluginExtensionTypes,
} from '@grafana/data';
import { usePluginLinks, usePluginComponents, config } from '@grafana/runtime';
import { usePluginLinks, usePluginComponents } from '@grafana/runtime';
import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants';
import { TraceViewPluginExtensionContext } from '../types/trace';
@@ -47,6 +47,13 @@ jest.mock('app/core/copy/appNotification', () => ({
})),
}));
// Mock config
jest.mock('../../../../../core/config', () => ({
config: {
feedbackLinksEnabled: false, // Default to false to avoid interference with tests
},
}));
// Mock navigator.clipboard
Object.assign(navigator, {
clipboard: {
@@ -120,7 +127,6 @@ describe('TracePageHeader test', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
config.feedbackLinksEnabled = false; // Default to false to avoid interference with tests
});
it('should render the new trace header', () => {
@@ -432,7 +438,9 @@ describe('TracePageHeader test', () => {
});
it('should render feedback button when feedbackLinksEnabled is true', () => {
config.feedbackLinksEnabled = true;
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
setup();
@@ -445,7 +453,9 @@ describe('TracePageHeader test', () => {
it('should display tooltip for feedback button', async () => {
const user = userEvent.setup();
config.feedbackLinksEnabled = true;
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
setup();
@@ -459,7 +469,9 @@ describe('TracePageHeader test', () => {
});
it('should render feedback button with correct styling and icon', () => {
config.feedbackLinksEnabled = true;
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
setup();
@@ -26,13 +26,7 @@ import {
PluginExtensionPoints,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
reportInteraction,
renderLimitedComponents,
usePluginComponents,
usePluginLinks,
config,
} from '@grafana/runtime';
import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime';
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import {
@@ -52,6 +46,7 @@ import {
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { config } from '../../../../../core/config';
import { downloadTraceAsJson } from '../../../../inspector/utils/download';
import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
+1 -1
View File
@@ -1,5 +1,5 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -15,8 +15,9 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { Button, Spinner, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
import { dataFrameToLogsModel } from '../logs/logsModel';
+1 -1
View File
@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
/** @deprecated */
export const getPanelInspectorStyles = stylesFactory(() => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { PanelPluginMeta, PluginState, unEscapeStringFromRegex } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config } from 'app/core/config';
export function getAllPanelPluginMeta(): PanelPluginMeta[] {
const allPanels = config.panels;
@@ -11,7 +11,6 @@ import {
toDataFrame,
VisualizationSuggestionScore,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import {
BarGaugeDisplayMode,
BigValueColorMode,
@@ -21,6 +20,7 @@ import {
VizOrientation,
} from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
import { config } from 'app/core/config';
import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin';
import { pluginImporter } from 'app/features/plugins/importer/pluginImporter';
@@ -3,6 +3,7 @@ import { from, forkJoin, timeout, lastValueFrom, catchError, of } from 'rxjs';
import { PanelPlugin, PluginError } from '@grafana/data';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { Settings } from 'app/core/config';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { StoreState, ThunkResult } from 'app/types/store';
@@ -300,7 +301,7 @@ export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> =
function updatePanels() {
return getBackendSrv()
.get('/api/frontend/settings')
.then((settings) => {
.then((settings: Settings) => {
config.panels = settings.panels;
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { uniq } from 'lodash';
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
const profileRoutes: RouteDescriptor[] = [
@@ -57,7 +57,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
const repositoryName = data?.metadata?.name;
const settings = useGetFrontendSettingsQuery();
const [submitData, request] = useCreateOrUpdateRepository(repositoryName);
const navigate = useNavigate();
const {
register,
handleSubmit,
@@ -78,6 +77,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
const isEdit = Boolean(repositoryName);
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [type, readOnly] = watch(['type', 'readOnly']);
const targetOptions = useMemo(() => getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]);
const isGitBased = isGitProvider(type);
@@ -104,6 +104,17 @@ export function ConfigForm({ data }: ConfigFormProps) {
const localFields = type === 'local' ? getLocalProviderFields(type) : null;
const hasTokenInstructions = getHasTokenInstructions(type);
// TODO: this should be removed after 12.2 is released
useEffect(() => {
if (isGitBased && !data?.secure?.token) {
setTokenConfigured(false);
setError('token', {
type: 'manual',
message: `Enter your ${gitFields?.tokenConfig.label ?? 'access token'}`,
});
}
}, [data, gitFields, setTokenConfigured, setError, isGitBased]);
useEffect(() => {
if (request.isSuccess) {
const formData = getValues();
@@ -115,9 +126,11 @@ export function ConfigForm({ data }: ConfigFormProps) {
});
reset(formData);
setTimeout(() => navigate(PROVISIONING_URL), 300);
setTimeout(() => {
navigate('/admin/provisioning');
}, 300);
}
}, [request.isSuccess, reset, getValues, repositoryName, navigate]);
}, [request.isSuccess, reset, getValues, navigate, repositoryName]);
const onSubmit = async (form: RepositoryFormData) => {
setIsLoading(true);
@@ -1,277 +0,0 @@
import { QueryStatus } from '@reduxjs/toolkit/query';
import { render, screen, waitFor } from 'test/test-utils';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
import { ConnectionForm } from './ConnectionForm';
jest.mock('../hooks/useCreateOrUpdateConnection', () => ({
useCreateOrUpdateConnection: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
const mockSubmitData = jest.fn();
const mockUseCreateOrUpdateConnection = useCreateOrUpdateConnection as jest.MockedFunction<
typeof useCreateOrUpdateConnection
>;
type MockRequestState = {
status: QueryStatus;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
error?: unknown;
reset: jest.Mock;
};
const createMockRequestState = (overrides: Partial<MockRequestState> = {}): MockRequestState => ({
status: QueryStatus.uninitialized,
isLoading: false,
isSuccess: false,
isError: false,
reset: jest.fn(),
...overrides,
});
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
metadata: { name: 'test-connection' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/12345678',
github: {
appID: '123456',
installationID: '12345678',
},
},
secure: {
privateKey: { name: 'configured' },
},
status: {
state: 'connected',
health: { healthy: true },
observedGeneration: 1,
},
...overrides,
});
interface SetupOptions {
data?: Connection;
requestState?: Partial<MockRequestState>;
}
function setup(options: SetupOptions = {}) {
const { data, requestState = {} } = options;
mockUseCreateOrUpdateConnection.mockReturnValue([
mockSubmitData,
createMockRequestState(requestState) as unknown as ReturnType<typeof useCreateOrUpdateConnection>[1],
]);
return {
mockSubmitData,
...render(<ConnectionForm data={data} />),
};
}
describe('ConnectionForm', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSubmitData.mockResolvedValue(undefined);
});
describe('Rendering - Create Mode', () => {
it('should render all form fields', () => {
setup();
expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument();
expect(screen.getByLabelText(/^GitHub App ID/)).toBeInTheDocument();
expect(screen.getByLabelText(/^GitHub Installation ID/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toBeInTheDocument();
});
it('should render Save button', () => {
setup();
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
});
it('should not render Delete button in create mode', () => {
setup();
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});
it('should have Provider field disabled', () => {
setup();
expect(screen.getByLabelText(/^Provider/)).toBeDisabled();
});
});
describe('Rendering - Edit Mode', () => {
it('should populate form fields with existing connection data', () => {
setup({ data: createMockConnection() });
expect(screen.getByLabelText(/^GitHub App ID/)).toHaveValue('123456');
expect(screen.getByLabelText(/^GitHub Installation ID/)).toHaveValue('12345678');
});
it('should render Delete button in edit mode', () => {
setup({ data: createMockConnection() });
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('should show configured state for private key', () => {
setup({ data: createMockConnection() });
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toHaveValue('configured');
});
});
describe('Form Validation', () => {
it('should show required error and not submit when fields are empty', async () => {
const { user, mockSubmitData } = setup();
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getAllByText('This field is required')).toHaveLength(3);
});
expect(mockSubmitData).not.toHaveBeenCalled();
});
});
describe('Form Submission - Create', () => {
it('should call submitData with correct data on valid submission', async () => {
const { user, mockSubmitData } = setup();
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockSubmitData).toHaveBeenCalledWith(
{
type: 'github',
github: {
appID: '123456',
installationID: '12345678',
},
},
'-----BEGIN RSA PRIVATE KEY-----'
);
});
});
});
describe('Form Submission - Edit', () => {
it('should allow submission without changing private key', async () => {
const { user, mockSubmitData } = setup({ data: createMockConnection() });
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockSubmitData).toHaveBeenCalledWith(
{
type: 'github',
github: {
appID: '123456',
installationID: '12345678',
},
},
'configured'
);
});
});
});
describe('Loading State', () => {
it('should disable Save button while loading', () => {
setup({ requestState: { isLoading: true } });
const saveButton = screen.getByRole('button', { name: /saving/i });
expect(saveButton).toBeDisabled();
});
it('should show "Saving..." text while loading', () => {
setup({ requestState: { isLoading: true } });
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should map API error for appID to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'appID', detail: 'Invalid App ID' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid App ID')).toBeInTheDocument();
});
});
it('should map API error for installationID to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'installationID', detail: 'Invalid Installation ID' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid Installation ID')).toBeInTheDocument();
});
});
it('should map API error for privateKey to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'secure.privateKey', detail: 'Invalid Private Key format' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), 'invalid-key');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid Private Key format')).toBeInTheDocument();
});
});
});
});

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