Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4083cf78d4 | |||
| 97af86efb2 | |||
| f58ab2a6a1 |
@@ -547,11 +547,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingCentralAlertHistory?: boolean;
|
||||
/**
|
||||
* Enable new grouped navigation structure for Alerting
|
||||
* @default false
|
||||
*/
|
||||
alertingNavigationV2?: boolean;
|
||||
/**
|
||||
* Preserve plugin proxy trailing slash.
|
||||
* @default false
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
|
||||
import { useId, memo, HTMLAttributes, SVGProps } from 'react';
|
||||
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
|
||||
import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks';
|
||||
import { getBarEndcapColors, getGradientCss } from './colors';
|
||||
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
|
||||
import { drawRadialArcPath, toRad } from './utils';
|
||||
|
||||
@@ -29,11 +30,6 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
|
||||
|
||||
type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient;
|
||||
|
||||
const ENDPOINT_MARKER_MIN_ANGLE = 10;
|
||||
const DOT_OPACITY = 0.5;
|
||||
const DOT_RADIUS_FACTOR = 0.4;
|
||||
const MAX_DOT_RADIUS = 8;
|
||||
|
||||
export const RadialArcPath = memo(
|
||||
({
|
||||
arcLengthDeg,
|
||||
@@ -68,67 +64,25 @@ export const RadialArcPath = memo(
|
||||
const xEnd = centerX + radius * Math.cos(endRadians);
|
||||
const yEnd = centerY + radius * Math.sin(endRadians);
|
||||
|
||||
const dotRadius =
|
||||
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
||||
|
||||
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX };
|
||||
|
||||
const pathProps: SVGProps<SVGPathElement> = {};
|
||||
let barEndcapColors: [string, string] | undefined;
|
||||
let endpointMarks: ReactNode = null;
|
||||
if (isGradient) {
|
||||
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
|
||||
|
||||
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
|
||||
switch (endpointMarker) {
|
||||
case 'point':
|
||||
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(
|
||||
rest.gradient!,
|
||||
fieldDisplay.display.percent
|
||||
);
|
||||
endpointMarks = (
|
||||
<>
|
||||
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
|
||||
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
|
||||
)}
|
||||
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case 'glow':
|
||||
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
|
||||
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
|
||||
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
|
||||
endpointMarks =
|
||||
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
|
||||
<path
|
||||
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
|
||||
fill="none"
|
||||
strokeWidth={barWidth}
|
||||
stroke={endpointMarkerGlowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
) : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (barEndcaps) {
|
||||
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
|
||||
}
|
||||
|
||||
pathProps.fill = 'none';
|
||||
pathProps.stroke = 'white';
|
||||
} else {
|
||||
bgDivStyle.backgroundColor = rest.color;
|
||||
|
||||
pathProps.fill = 'none';
|
||||
pathProps.stroke = rest.color;
|
||||
}
|
||||
|
||||
let barEndcapColors: [string, string] | undefined;
|
||||
if (barEndcaps) {
|
||||
barEndcapColors = isGradient
|
||||
? getBarEndcapColors(rest.gradient, fieldDisplay.display.percent)
|
||||
: [rest.color, rest.color];
|
||||
}
|
||||
|
||||
const pathEl = (
|
||||
<path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} />
|
||||
);
|
||||
@@ -158,7 +112,23 @@ export const RadialArcPath = memo(
|
||||
)}
|
||||
</g>
|
||||
|
||||
{endpointMarks}
|
||||
{endpointMarker && (
|
||||
<RadialArcPathEndpointMarks
|
||||
startAngle={angle}
|
||||
arcLengthDeg={arcLengthDeg}
|
||||
dimensions={dimensions}
|
||||
endpointMarker={endpointMarker}
|
||||
fieldDisplay={fieldDisplay}
|
||||
xStart={xStart}
|
||||
xEnd={xEnd}
|
||||
yStart={yStart}
|
||||
yEnd={yEnd}
|
||||
roundedBars={roundedBars}
|
||||
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
|
||||
glowFilter={glowFilter}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { RadialArcPathEndpointMarks, RadialArcPathEndpointMarksProps } from './RadialArcPathEndpointMarks';
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
|
||||
const ser = new XMLSerializer();
|
||||
|
||||
const expectHTML = (result: RenderResult, expected: string) => {
|
||||
let actual = ser.serializeToString(result.asFragment()).replace(/xmlns=".*?" /g, '');
|
||||
expect(actual).toEqual(expected.replace(/^\s*|\n/gm, ''));
|
||||
};
|
||||
|
||||
describe('RadialArcPathEndpointMarks', () => {
|
||||
const defaultDimensions = Object.freeze({
|
||||
centerX: 100,
|
||||
centerY: 100,
|
||||
radius: 80,
|
||||
barWidth: 20,
|
||||
vizWidth: 200,
|
||||
vizHeight: 200,
|
||||
margin: 10,
|
||||
barIndex: 0,
|
||||
thresholdsBarRadius: 0,
|
||||
thresholdsBarWidth: 0,
|
||||
thresholdsBarSpacing: 0,
|
||||
scaleLabelsFontSize: 0,
|
||||
scaleLabelsSpacing: 0,
|
||||
scaleLabelsRadius: 0,
|
||||
gaugeBottomY: 0,
|
||||
}) satisfies RadialGaugeDimensions;
|
||||
|
||||
const defaultFieldDisplay = Object.freeze({
|
||||
name: 'Test',
|
||||
field: {},
|
||||
display: { text: '50', numeric: 50, color: '#FF0000' },
|
||||
hasLinks: false,
|
||||
}) satisfies FieldDisplay;
|
||||
|
||||
const defaultProps = Object.freeze({
|
||||
arcLengthDeg: 90,
|
||||
dimensions: defaultDimensions,
|
||||
fieldDisplay: defaultFieldDisplay,
|
||||
startAngle: 0,
|
||||
xStart: 100,
|
||||
xEnd: 150,
|
||||
yStart: 100,
|
||||
yEnd: 50,
|
||||
}) satisfies Omit<RadialArcPathEndpointMarksProps, 'color' | 'gradient' | 'endpointMarker'>;
|
||||
|
||||
it('renders the expected marks when endpointMarker is "point" w/ a static color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="point" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><circle cx=\"100\" cy=\"100\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the expected marks when endpointMarker is "point" w/ a gradient color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks
|
||||
{...defaultProps}
|
||||
endpointMarker="point"
|
||||
gradient={[
|
||||
{ color: '#00FF00', percent: 0 },
|
||||
{ color: '#0000FF', percent: 1 },
|
||||
]}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><circle cx=\"100\" cy=\"100\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#fbfbfb\" opacity=\"0.5\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the expected marks when endpointMarker is "glow" w/ a static color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="glow" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><path d=\"M 113.89185421335443 21.215379759023364 A 80 80 0 0 1 150 50\" fill=\"none\" stroke-width=\"20\" stroke-linecap=\"butt\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the expected marks when endpointMarker is "glow" w/ a gradient color', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks
|
||||
{...defaultProps}
|
||||
endpointMarker="glow"
|
||||
gradient={[
|
||||
{ color: '#00FF00', percent: 0 },
|
||||
{ color: '#0000FF', percent: 1 },
|
||||
]}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><path d=\"M 113.89185421335443 21.215379759023364 A 80 80 0 0 1 150 50\" fill=\"none\" stroke-width=\"20\" stroke-linecap=\"butt\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the start mark when arcLengthDeg is less than the minimum angle for "point" endpointMarker', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} arcLengthDeg={5} endpointMarker="point" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/></svg>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render anything when arcLengthDeg is less than the minimum angle for "glow" endpointMarker', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
<RadialArcPathEndpointMarks {...defaultProps} arcLengthDeg={5} endpointMarker="glow" color="#FF0000" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render anything if endpointMarker is some other value', () => {
|
||||
expectHTML(
|
||||
render(
|
||||
<svg role="img">
|
||||
{/* @ts-ignore: confirming the component doesn't throw */}
|
||||
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="foo" />
|
||||
</svg>
|
||||
),
|
||||
'<svg role=\"img\"/>'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { getEndpointMarkerColors, getGuideDotColor } from './colors';
|
||||
import { GradientStop, RadialGaugeDimensions } from './types';
|
||||
import { toRad } from './utils';
|
||||
|
||||
interface RadialArcPathEndpointMarksPropsBase {
|
||||
arcLengthDeg: number;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
fieldDisplay: FieldDisplay;
|
||||
endpointMarker: 'point' | 'glow';
|
||||
roundedBars?: boolean;
|
||||
startAngle: number;
|
||||
glowFilter?: string;
|
||||
endpointMarkerGlowFilter?: string;
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
}
|
||||
|
||||
interface RadialArcPathEndpointMarksPropsWithColor extends RadialArcPathEndpointMarksPropsBase {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface RadialArcPathEndpointMarksPropsWithGradient extends RadialArcPathEndpointMarksPropsBase {
|
||||
gradient: GradientStop[];
|
||||
}
|
||||
|
||||
export type RadialArcPathEndpointMarksProps =
|
||||
| RadialArcPathEndpointMarksPropsWithColor
|
||||
| RadialArcPathEndpointMarksPropsWithGradient;
|
||||
|
||||
const ENDPOINT_MARKER_MIN_ANGLE = 10;
|
||||
const DOT_OPACITY = 0.5;
|
||||
const DOT_RADIUS_FACTOR = 0.4;
|
||||
const MAX_DOT_RADIUS = 8;
|
||||
|
||||
export function RadialArcPathEndpointMarks({
|
||||
startAngle: angle,
|
||||
arcLengthDeg,
|
||||
dimensions,
|
||||
endpointMarker,
|
||||
fieldDisplay,
|
||||
xStart,
|
||||
xEnd,
|
||||
yStart,
|
||||
yEnd,
|
||||
roundedBars,
|
||||
endpointMarkerGlowFilter,
|
||||
glowFilter,
|
||||
...rest
|
||||
}: RadialArcPathEndpointMarksProps) {
|
||||
const isGradient = 'gradient' in rest;
|
||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||
const endRadians = toRad(angle + arcLengthDeg);
|
||||
|
||||
switch (endpointMarker) {
|
||||
case 'point': {
|
||||
const [pointColorStart, pointColorEnd] = isGradient
|
||||
? getEndpointMarkerColors(rest.gradient, fieldDisplay.display.percent)
|
||||
: [getGuideDotColor(rest.color), getGuideDotColor(rest.color)];
|
||||
|
||||
const dotRadius =
|
||||
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
|
||||
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
|
||||
)}
|
||||
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'glow':
|
||||
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
|
||||
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
|
||||
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
|
||||
if (arcLengthDeg <= ENDPOINT_MARKER_MIN_ANGLE) {
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<path
|
||||
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
|
||||
fill="none"
|
||||
strokeWidth={barWidth}
|
||||
stroke={endpointMarkerGlowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape
|
||||
const GRAY_05 = '#111217';
|
||||
const GRAY_90 = '#fbfbfb';
|
||||
const CONTRAST_THRESHOLD_MAX = 4.5;
|
||||
const getGuideDotColor = (color: string): string => {
|
||||
export const getGuideDotColor = (color: string): string => {
|
||||
const darkColor = GRAY_05;
|
||||
const lightColor = GRAY_90;
|
||||
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -316,6 +315,12 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// sort.Slice(parsedResults.Hits, func(i, j int) bool {
|
||||
// // Just sorting by resource for now. The rest should be sorted by search score already
|
||||
// return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource
|
||||
// })
|
||||
// }
|
||||
|
||||
result, err := s.client.Search(ctx, searchRequest)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
@@ -332,14 +337,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(searchRequest.SortBy) == 0 {
|
||||
// default sort by resource descending ( folders then dashboards ) then title
|
||||
sort.Slice(parsedResults.Hits, func(i, j int) bool {
|
||||
// Just sorting by resource for now. The rest should be sorted by search score already
|
||||
return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource
|
||||
})
|
||||
}
|
||||
|
||||
s.write(w, parsedResults)
|
||||
}
|
||||
|
||||
@@ -428,6 +425,18 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
|
||||
}
|
||||
searchRequest.SortBy = append(searchRequest.SortBy, s)
|
||||
}
|
||||
} else if searchRequest.Query == "" {
|
||||
// When no query exists, return the results in a predictable order
|
||||
searchRequest.SortBy = []*resourcepb.ResourceSearchRequest_Sort{
|
||||
{
|
||||
Field: resource.SEARCH_FIELD_GROUP_RESOURCE, // folders then dashboards
|
||||
Desc: true,
|
||||
},
|
||||
{
|
||||
Field: resource.SEARCH_FIELD_TITLE, // then title
|
||||
Desc: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The facet term fields
|
||||
|
||||
@@ -907,14 +907,6 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: false, // changes navtree from backend
|
||||
},
|
||||
{
|
||||
Name: "alertingNavigationV2",
|
||||
Description: "Enable new grouped navigation structure for Alerting",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: false, // changes navtree from backend
|
||||
Expression: "false", // Off by default
|
||||
},
|
||||
{
|
||||
Name: "pluginProxyPreserveTrailingSlash",
|
||||
Description: "Preserve plugin proxy trailing slash.",
|
||||
|
||||
Generated
-1
@@ -125,7 +125,6 @@ alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
||||
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
||||
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false
|
||||
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
|
||||
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
|
||||
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
|
||||
|
||||
|
Generated
-4
@@ -379,10 +379,6 @@ const (
|
||||
// Enables the new central alert history.
|
||||
FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory"
|
||||
|
||||
// FlagAlertingNavigationV2
|
||||
// Enable new grouped navigation structure for Alerting
|
||||
FlagAlertingNavigationV2 = "alertingNavigationV2"
|
||||
|
||||
// FlagPluginProxyPreserveTrailingSlash
|
||||
// Preserve plugin proxy trailing slash.
|
||||
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
|
||||
|
||||
-13
@@ -348,19 +348,6 @@
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingNavigationV2",
|
||||
"resourceVersion": "1767827323622",
|
||||
"creationTimestamp": "2026-01-07T23:08:43Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable new grouped navigation structure for Alerting",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingNotificationHistory",
|
||||
|
||||
@@ -433,214 +433,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingNavigationV2) {
|
||||
return s.buildAlertNavLinksLegacy(c)
|
||||
}
|
||||
|
||||
// V2 Navigation - New grouped structure
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
var alertChildNavs []*navtree.NavLink
|
||||
|
||||
// 1. Alert activity (parent with tabs: Alerts, Active notifications)
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
var alertActivityChildren []*navtree.NavLink
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||
// Alerts tab
|
||||
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",
|
||||
})
|
||||
}
|
||||
// Active notifications tab
|
||||
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-activity",
|
||||
Url: s.cfg.AppSubURL + "/alerting/alerts",
|
||||
Icon: "bell",
|
||||
IsNew: true,
|
||||
Children: alertActivityChildren,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Alert rules (parent with tabs: Alert rules, Recently deleted)
|
||||
var alertRulesChildren []*navtree.NavLink
|
||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Notification configuration (parent with tabs: Contact points, Notification policies, Templates, Time intervals)
|
||||
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...)) {
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
if hasAccess(ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
|
||||
ac.EvalPermission(ac.ActionAlertingRoutesRead),
|
||||
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
|
||||
)) {
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Templates
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Time intervals
|
||||
if hasAccess(ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
|
||||
ac.EvalPermission(ac.ActionAlertingRoutesRead),
|
||||
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
|
||||
)) {
|
||||
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 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,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Insights (parent with tabs: System Insights, Alert state history)
|
||||
var insightsChildren []*navtree.NavLink
|
||||
|
||||
// System Insights
|
||||
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))) {
|
||||
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: "insights-history",
|
||||
Url: s.cfg.AppSubURL + "/alerting/history",
|
||||
Icon: "history",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(insightsChildren) > 0 {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
Text: "Insights",
|
||||
SubTitle: "Analytics and history for alerting",
|
||||
Id: "insights",
|
||||
Url: s.cfg.AppSubURL + "/alerting/insights",
|
||||
Icon: "chart-line",
|
||||
Children: insightsChildren,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Settings (parent with tab: Settings)
|
||||
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",
|
||||
SubTitle: "Alerting configuration and administration",
|
||||
Id: "alerting-settings",
|
||||
Url: s.cfg.AppSubURL + "/alerting/admin",
|
||||
Icon: "cog",
|
||||
Children: settingsChildren,
|
||||
})
|
||||
}
|
||||
|
||||
// Create alert rule (hidden from tabs)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
if len(alertChildNavs) > 0 {
|
||||
var alertNav = navtree.NavLink{
|
||||
Text: "Alerting",
|
||||
SubTitle: "Learn about problems in your systems moments after they occur",
|
||||
Id: navtree.NavIDAlerting,
|
||||
Icon: "bell",
|
||||
Children: alertChildNavs,
|
||||
SortWeight: navtree.WeightAlerting,
|
||||
Url: s.cfg.AppSubURL + "/alerting",
|
||||
}
|
||||
|
||||
return &alertNav
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) buildAlertNavLinksLegacy(c *contextmodel.ReqContext) *navtree.NavLink {
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
var alertChildNavs []*navtree.NavLink
|
||||
|
||||
@@ -648,7 +440,7 @@ func (s *ServiceImpl) buildAlertNavLinksLegacy(c *contextmodel.ReqContext) *navt
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||
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: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
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_FeatureToggle(t *testing.T) {
|
||||
reqCtx := setupTestContext()
|
||||
permissions := fullPermissions()
|
||||
|
||||
t.Run("Should use legacy navigation when flag is off", func(t *testing.T) {
|
||||
service := setupTestService(permissions) // No feature flags
|
||||
|
||||
navLink := service.buildAlertNavLinks(reqCtx)
|
||||
require.NotNil(t, navLink)
|
||||
require.Equal(t, "Alerting", navLink.Text)
|
||||
require.Equal(t, navtree.NavIDAlerting, navLink.Id)
|
||||
|
||||
// Legacy structure: flat children without nested items
|
||||
require.NotEmpty(t, navLink.Children)
|
||||
alertList := findNavLink(navLink, "alert-list")
|
||||
receivers := findNavLink(navLink, "receivers")
|
||||
|
||||
require.NotNil(t, alertList, "Should have alert-list in legacy navigation")
|
||||
require.NotNil(t, receivers, "Should have receivers in legacy navigation")
|
||||
require.Empty(t, alertList.Children, "Legacy items should not have nested children")
|
||||
require.Empty(t, receivers.Children, "Legacy items should not have nested children")
|
||||
})
|
||||
|
||||
t.Run("Should use V2 navigation when flag is on", func(t *testing.T) {
|
||||
service := setupTestService(permissions, "alertingNavigationV2")
|
||||
|
||||
navLink := service.buildAlertNavLinks(reqCtx)
|
||||
require.NotNil(t, navLink)
|
||||
require.Equal(t, "Alerting", navLink.Text)
|
||||
require.Equal(t, navtree.NavIDAlerting, navLink.Id)
|
||||
|
||||
// V2 structure: grouped parents with nested children
|
||||
require.NotEmpty(t, navLink.Children)
|
||||
|
||||
// Verify all expected parent items exist with children
|
||||
expectedParents := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
|
||||
for _, parentId := range expectedParents {
|
||||
parent := findNavLink(navLink, parentId)
|
||||
require.NotNil(t, parent, "Should have %s parent in V2 navigation", parentId)
|
||||
require.NotEmpty(t, parent.Children, "V2 parent %s should have children", parentId)
|
||||
}
|
||||
|
||||
// Verify alert-rules has expected tab
|
||||
alertRules := findNavLink(navLink, "alert-rules")
|
||||
require.True(t, hasChildWithId(alertRules, "alert-rules-list"), "Should have alert-rules-list tab")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildAlertNavLinks_Legacy(t *testing.T) {
|
||||
reqCtx := setupTestContext()
|
||||
|
||||
t.Run("Should include all expected items in legacy navigation", func(t *testing.T) {
|
||||
service := setupTestService(fullPermissions())
|
||||
navLink := service.buildAlertNavLinksLegacy(reqCtx)
|
||||
require.NotNil(t, navLink)
|
||||
|
||||
expectedIds := []string{"alert-list", "receivers", "am-routes", "alerting-admin"}
|
||||
for _, expectedId := range expectedIds {
|
||||
require.NotNil(t, findNavLink(navLink, expectedId), "Should have %s in legacy navigation", expectedId)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Should respect permissions in legacy navigation", func(t *testing.T) {
|
||||
limitedPermissions := []ac.Permission{
|
||||
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
|
||||
}
|
||||
limitedService := setupTestService(limitedPermissions)
|
||||
|
||||
navLink := limitedService.buildAlertNavLinksLegacy(reqCtx)
|
||||
require.NotNil(t, navLink)
|
||||
|
||||
require.NotNil(t, findNavLink(navLink, "alert-list"), "Should have alert rules with read permission")
|
||||
require.Nil(t, findNavLink(navLink, "receivers"), "Should not have contact points without notification permissions")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildAlertNavLinks_V2(t *testing.T) {
|
||||
reqCtx := setupTestContext()
|
||||
allFeatureFlags := []string{"alertingNavigationV2", "alertingTriage", "alertingCentralAlertHistory", "alertRuleRestore", "alertingRuleRecoverDeleted"}
|
||||
service := setupTestService(fullPermissions(), allFeatureFlags...)
|
||||
|
||||
t.Run("Should have correct parent structure in V2 navigation", 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 in V2 navigation", 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 in V2 navigation", func(t *testing.T) {
|
||||
limitedPermissions := []ac.Permission{
|
||||
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
|
||||
}
|
||||
limitedService := setupTestService(limitedPermissions, "alertingNavigationV2")
|
||||
|
||||
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 from V2 navigation", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { reportInteraction } from '@grafana/runtime';
|
||||
import { ScrollContainer, useStyles2 } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { setBookmark } from 'app/core/reducers/navBarTree';
|
||||
import { shouldUseAlertingNavigationV2 } from 'app/features/alerting/unified/featureToggles';
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { MegaMenuExtensionPoint } from './MegaMenuExtensionPoint';
|
||||
@@ -38,25 +37,9 @@ export const MegaMenu = memo(
|
||||
const pinnedItems = usePinnedItems();
|
||||
|
||||
// Remove profile + help from tree
|
||||
// For Alerting V2 navigation, flatten the sidebar to show only top-level items (hide nested children/tabs)
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
const navItems = navTree
|
||||
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
||||
.map((item) => {
|
||||
const enriched = enrichWithInteractionTracking(item, state.megaMenuDocked);
|
||||
// If this is Alerting section and V2 navigation is enabled, flatten children for sidebar display
|
||||
// Children are still available in navIndex for breadcrumbs and page navigation
|
||||
if (useV2Nav && 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;
|
||||
});
|
||||
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
|
||||
|
||||
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
|
||||
if (bookmarksItem) {
|
||||
|
||||
@@ -35,18 +35,11 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
||||
|
||||
if (shouldAddCrumb) {
|
||||
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
|
||||
// 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) {
|
||||
// Add tab to breadcrumbs if it's not the first active child
|
||||
if (activeChildIndex > 0) {
|
||||
const activeChild = node.children?.[activeChildIndex];
|
||||
if (activeChild) {
|
||||
// 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: activeChild.text, href: activeChild.url ?? '' });
|
||||
}
|
||||
}
|
||||
crumbs.unshift({ text: node.text, href: node.url ?? '' });
|
||||
|
||||
@@ -56,17 +56,6 @@ 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([
|
||||
@@ -223,13 +212,6 @@ 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'],
|
||||
|
||||
@@ -14,7 +14,6 @@ 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';
|
||||
@@ -114,9 +113,8 @@ const AlertGroups = () => {
|
||||
};
|
||||
|
||||
function AlertGroupsPage() {
|
||||
const { navId, pageNav } = useAlertActivityNav();
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
|
||||
<AlertmanagerPageWrapper navId="groups" accessType="instance">
|
||||
<AlertGroups />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { produce } from 'immer';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen, testWithFeatureToggles, userEvent, within } from 'test/test-utils';
|
||||
import { render, screen, userEvent, within } from 'test/test-utils';
|
||||
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||
@@ -140,39 +140,6 @@ const getRootRoute = async () => {
|
||||
};
|
||||
|
||||
describe('NotificationPolicies', () => {
|
||||
describe('V2 Navigation Mode', () => {
|
||||
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
|
||||
|
||||
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 in V2 mode', 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(() => ({
|
||||
|
||||
@@ -12,8 +12,6 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
||||
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
||||
import { shouldUseAlertingNavigationV2 } from './featureToggles';
|
||||
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||
|
||||
@@ -108,32 +106,9 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q
|
||||
};
|
||||
}
|
||||
|
||||
const NotificationPoliciesContent = () => {
|
||||
const { selectedAlertmanager = '' } = useAlertmanager();
|
||||
return (
|
||||
<>
|
||||
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
|
||||
<NotificationPoliciesList />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function NotificationPoliciesPage() {
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
const { navId, pageNav } = useNotificationConfigNav();
|
||||
|
||||
// In V2 mode, show only notification policies (no internal tabs)
|
||||
if (useV2Nav) {
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||
<NotificationPoliciesContent />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy mode: Show internal tabs (backward compatible)
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
|
||||
<NotificationPoliciesTabs />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,56 +1,13 @@
|
||||
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 { shouldUseAlertingNavigationV2 } from './featureToggles';
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from './hooks/useAbilities';
|
||||
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
||||
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||
|
||||
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() {
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
|
||||
function NotificationTemplates() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* In V2 mode, show templates list on base route */}
|
||||
{useV2Nav && <Route path="" element={<TemplatesList />} />}
|
||||
<Route path="new" element={<NewMessageTemplate />} />
|
||||
<Route path=":name/edit" element={<EditMessageTemplate />} />
|
||||
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
|
||||
@@ -58,21 +15,4 @@ function NotificationTemplatesRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationTemplatesPage() {
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
const { navId, pageNav } = useNotificationConfigNav();
|
||||
|
||||
// In V2 mode, wrap with page wrapper for proper navigation
|
||||
if (useV2Nav) {
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
||||
<NotificationTemplatesRoutes />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// In legacy mode, just render routes (templates are accessed via ContactPoints page tabs)
|
||||
return <NotificationTemplatesRoutes />;
|
||||
}
|
||||
|
||||
export default withPageErrorBoundary(NotificationTemplatesPage);
|
||||
export default withPageErrorBoundary(NotificationTemplates);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { render, screen, testWithFeatureToggles } 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', () => {
|
||||
describe('V2 Navigation Mode', () => {
|
||||
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('returns null in legacy mode', () => {
|
||||
// This test verifies that the component returns null when V2 is disabled
|
||||
// The feature toggle is controlled by testWithFeatureToggles, so we test it separately
|
||||
const { container } = render(<TimeIntervalsPage />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/time-intervals'],
|
||||
},
|
||||
});
|
||||
// In V2 mode (enabled by testWithFeatureToggles), it should render content
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
||||
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
||||
import { shouldUseAlertingNavigationV2 } from './featureToggles';
|
||||
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 useV2Nav = shouldUseAlertingNavigationV2();
|
||||
const { navId, pageNav } = useNotificationConfigNav();
|
||||
|
||||
// In V2 mode, wrap with page wrapper for proper navigation
|
||||
// AlertmanagerPageWrapper provides AlertmanagerContext, so TimeIntervalsPageContent can use useAlertmanager
|
||||
if (useV2Nav) {
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||
<TimeIntervalsPageContent />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy mode: not used (handled by NotificationPoliciesPage)
|
||||
return null;
|
||||
}
|
||||
|
||||
export default withPageErrorBoundary(TimeIntervalsPage);
|
||||
+1
-33
@@ -1,14 +1,6 @@
|
||||
import { MemoryHistoryBuildOptions } from 'history';
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
testWithFeatureToggles,
|
||||
userEvent,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from 'test/test-utils';
|
||||
import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
@@ -178,30 +170,6 @@ describe('contact points', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('V2 Navigation Mode', () => {
|
||||
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
|
||||
|
||||
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 in V2 mode', 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(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import {
|
||||
Alert,
|
||||
@@ -15,18 +13,15 @@ import {
|
||||
TabContent,
|
||||
TabsBar,
|
||||
Text,
|
||||
useStyles2,
|
||||
} 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';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import { shouldUseAlertingNavigationV2 } from '../../featureToggles';
|
||||
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';
|
||||
@@ -104,7 +99,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 />
|
||||
@@ -153,7 +148,7 @@ const ContactPointsTab = () => {
|
||||
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
|
||||
)}
|
||||
{ExportDrawer}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -163,7 +158,7 @@ const NotificationTemplatesTab = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Text variant="body" color="secondary">
|
||||
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
||||
@@ -184,7 +179,7 @@ const NotificationTemplatesTab = () => {
|
||||
)}
|
||||
</Stack>
|
||||
<NotificationTemplates />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -206,10 +201,6 @@ const useTabQueryParam = (defaultTab: ActiveTab) => {
|
||||
|
||||
export const ContactPointsPageContents = () => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// All hooks must be called unconditionally before any early returns
|
||||
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
||||
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
|
||||
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
|
||||
@@ -229,19 +220,6 @@ export const ContactPointsPageContents = () => {
|
||||
alertmanager: selectedAlertmanager!,
|
||||
});
|
||||
|
||||
// In V2 navigation mode, show only contact points (no internal tabs)
|
||||
// Templates are accessible via the sidebar navigation
|
||||
if (useV2Nav) {
|
||||
return (
|
||||
<>
|
||||
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
|
||||
<ContactPointsTab />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy mode: Show internal tabs (backward compatible)
|
||||
|
||||
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
|
||||
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
|
||||
|
||||
@@ -266,7 +244,7 @@ export const ContactPointsPageContents = () => {
|
||||
/>
|
||||
)}
|
||||
</TabsBar>
|
||||
<TabContent className={styles.tabContent}>
|
||||
<TabContent>
|
||||
<Stack direction="column">
|
||||
{showingContactPoints && <ContactPointsTab />}
|
||||
{showNotificationTemplates && <NotificationTemplatesTab />}
|
||||
@@ -303,16 +281,9 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabContent: css({
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
|
||||
function ContactPointsPage() {
|
||||
const { navId, pageNav } = useNotificationConfigNav();
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
||||
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
|
||||
<ContactPointsPageContents />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
||||
+1
-3
@@ -1,13 +1,11 @@
|
||||
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={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
|
||||
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
|
||||
<CentralAlertHistoryScene />
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
|
||||
+1
-3
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -19,10 +18,9 @@ function DeletedrulesPage() {
|
||||
rulerConfig: GRAFANA_RULER_CONFIG,
|
||||
filter: {}, // todo: add filters, and limit?????
|
||||
});
|
||||
const { navId, pageNav } = useAlertRulesNav();
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
|
||||
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
|
||||
<>
|
||||
{error && (
|
||||
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
|
||||
|
||||
@@ -31,8 +31,3 @@ export const shouldUseFullyCompatibleBackendFilters = () =>
|
||||
* Saved searches feature - allows users to save and apply search queries on the Alert Rules page.
|
||||
*/
|
||||
export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false;
|
||||
|
||||
/**
|
||||
* New grouped navigation structure for Alerting
|
||||
*/
|
||||
export const shouldUseAlertingNavigationV2 = () => config.featureToggles.alertingNavigationV2 ?? false;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
|
||||
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
@@ -15,13 +14,10 @@ import { PluginIntegrations } from './PluginIntegrations';
|
||||
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
|
||||
|
||||
function Home() {
|
||||
// When V2 navigation is enabled, don't show Insights tab on Home page
|
||||
// (Insights is available via the sidebar Insights menu instead)
|
||||
const insightsEnabled = (insightsIsAvailable() || isLocalDevEnv()) && !config.featureToggles.alertingNavigationV2;
|
||||
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
|
||||
// Memoize the scene so it's only created once and properly initialized
|
||||
const insightsScene = useMemo(() => getInsightsScenes(), []);
|
||||
const insightsScene = getInsightsScenes();
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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);
|
||||
@@ -1,187 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { getWrapper } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
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',
|
||||
},
|
||||
groups: {
|
||||
id: 'groups',
|
||||
text: 'Alert groups',
|
||||
url: '/alerting/groups',
|
||||
},
|
||||
'alert-alerts': {
|
||||
id: 'alert-alerts',
|
||||
text: 'Alerts',
|
||||
url: '/alerting/alerts',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultPreloadedState = {
|
||||
navIndex: mockNavIndex,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.alertingNavigationV2 = false;
|
||||
});
|
||||
|
||||
it('should return legacy navId when feature flag is off for /alerting/groups', () => {
|
||||
const wrapper = getWrapper({
|
||||
preloadedState: defaultPreloadedState,
|
||||
renderWithRouter: true,
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/groups'],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||
|
||||
expect(result.current.navId).toBe('groups');
|
||||
expect(result.current.pageNav).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return legacy navId when feature flag is off for /alerting/alerts', () => {
|
||||
const wrapper = getWrapper({
|
||||
preloadedState: defaultPreloadedState,
|
||||
renderWithRouter: true,
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/alerts'],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||
|
||||
expect(result.current.navId).toBe('alert-alerts');
|
||||
expect(result.current.pageNav).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return V2 navigation when feature flag is on for Alerts tab', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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 V2 navigation when feature flag is on for Active notifications tab', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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 fallback to legacy when alert-activity nav is missing', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
const store = configureStore({
|
||||
navIndex: {
|
||||
groups: mockNavIndex.groups,
|
||||
'alert-alerts': mockNavIndex['alert-alerts'],
|
||||
},
|
||||
});
|
||||
const wrapper = getWrapper({
|
||||
store,
|
||||
renderWithRouter: true,
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/groups'],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||
|
||||
expect(result.current.navId).toBe('groups');
|
||||
expect(result.current.pageNav).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||
|
||||
export function useAlertActivityNav() {
|
||||
const location = useLocation();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
|
||||
// If V2 navigation is not enabled, return legacy navId
|
||||
if (!useV2Nav) {
|
||||
if (location.pathname === '/alerting/groups') {
|
||||
return {
|
||||
navId: 'groups',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
if (location.pathname === '/alerting/alerts') {
|
||||
return {
|
||||
navId: 'alert-alerts',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
navId: undefined,
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const alertActivityNav = navIndex['alert-activity'];
|
||||
if (!alertActivityNav) {
|
||||
// Fallback to legacy
|
||||
if (location.pathname === '/alerting/groups') {
|
||||
return {
|
||||
navId: 'groups',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
if (location.pathname === '/alerting/alerts') {
|
||||
return {
|
||||
navId: 'alert-alerts',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { getWrapper } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'alert-list': {
|
||||
id: 'alert-list',
|
||||
text: 'Alert rules',
|
||||
url: '/alerting/list',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultPreloadedState = {
|
||||
navIndex: mockNavIndex,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.alertingNavigationV2 = false;
|
||||
});
|
||||
|
||||
it('should return legacy navId when feature flag is off', () => {
|
||||
const wrapper = getWrapper({
|
||||
preloadedState: defaultPreloadedState,
|
||||
renderWithRouter: true,
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/list'],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
|
||||
|
||||
expect(result.current.navId).toBe('alert-list');
|
||||
expect(result.current.pageNav).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return V2 navigation when feature flag is on', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||
|
||||
export function useAlertRulesNav() {
|
||||
const location = useLocation();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
|
||||
// If V2 navigation is not enabled, return legacy navId
|
||||
if (!useV2Nav) {
|
||||
return {
|
||||
navId: 'alert-list',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const alertRulesNav = navIndex['alert-rules'];
|
||||
if (!alertRulesNav) {
|
||||
// Fallback to legacy if V2 nav doesn't exist
|
||||
return {
|
||||
navId: 'alert-list',
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { getWrapper } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
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',
|
||||
},
|
||||
'alerts-history': {
|
||||
id: 'alerts-history',
|
||||
text: 'History',
|
||||
url: '/alerting/history',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultPreloadedState = {
|
||||
navIndex: mockNavIndex,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.alertingNavigationV2 = false;
|
||||
});
|
||||
|
||||
it('should return legacy navId when feature flag is off', () => {
|
||||
const wrapper = getWrapper({
|
||||
preloadedState: defaultPreloadedState,
|
||||
renderWithRouter: true,
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/history'],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useInsightsNav(), { wrapper });
|
||||
|
||||
expect(result.current.navId).toBe('alerts-history');
|
||||
expect(result.current.pageNav).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return V2 navigation when feature flag is on', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||
|
||||
export function useInsightsNav() {
|
||||
const location = useLocation();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
|
||||
// If V2 navigation is not enabled, return legacy navId
|
||||
if (!useV2Nav) {
|
||||
if (location.pathname === '/alerting/history') {
|
||||
return {
|
||||
navId: 'alerts-history',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
// For insights page, it doesn't exist in legacy, so return undefined
|
||||
return {
|
||||
navId: undefined,
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const insightsNav = navIndex.insights;
|
||||
if (!insightsNav) {
|
||||
// Fallback to legacy
|
||||
if (location.pathname === '/alerting/history') {
|
||||
return {
|
||||
navId: 'alerts-history',
|
||||
pageNav: undefined,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { getWrapper } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
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/routes?tab=time_intervals',
|
||||
},
|
||||
receivers: {
|
||||
id: 'receivers',
|
||||
text: 'Contact points',
|
||||
url: '/alerting/notifications',
|
||||
},
|
||||
'am-routes': {
|
||||
id: 'am-routes',
|
||||
text: 'Notification policies',
|
||||
url: '/alerting/routes',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultPreloadedState = {
|
||||
navIndex: mockNavIndex,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.alertingNavigationV2 = false;
|
||||
});
|
||||
|
||||
it('should return legacy navId when feature flag is off', () => {
|
||||
const wrapper = getWrapper({
|
||||
preloadedState: defaultPreloadedState,
|
||||
renderWithRouter: true,
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/notifications'],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
|
||||
|
||||
expect(result.current.navId).toBe('receivers');
|
||||
expect(result.current.pageNav).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return V2 navigation when feature flag is on', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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 V2 path', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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', () => {
|
||||
config.featureToggles.alertingNavigationV2 = true;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useSelector } from 'app/types/store';
|
||||
|
||||
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||
|
||||
export function useNotificationConfigNav() {
|
||||
const location = useLocation();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||
|
||||
// If V2 navigation is not enabled, return legacy navId based on current path
|
||||
if (!useV2Nav) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
// In V2 mode, check for dedicated route; in legacy mode, check for query param
|
||||
const isTimeIntervalsTab = useV2Nav
|
||||
? location.pathname === '/alerting/time-intervals'
|
||||
: location.pathname === '/alerting/routes' && location.search.includes('tab=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: useV2Nav ? '/alerting/time-intervals' : '/alerting/routes?tab=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,7 +20,6 @@ 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';
|
||||
@@ -116,14 +115,11 @@ 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={navId}
|
||||
pageNav={pageNav}
|
||||
navId="alert-list"
|
||||
isLoading={false}
|
||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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 { FilterView } from './FilterView';
|
||||
import { GroupedView } from './GroupedView';
|
||||
@@ -120,12 +119,10 @@ export function RuleListActions() {
|
||||
|
||||
export default function RuleListPage() {
|
||||
const { isApplying } = useApplyDefaultSearch();
|
||||
const { navId, pageNav } = useAlertRulesNav();
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper
|
||||
navId={navId}
|
||||
pageNav={pageNav}
|
||||
navId="alert-list"
|
||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||
isLoading={isApplying}
|
||||
actions={<RuleListActions />}
|
||||
|
||||
@@ -3,15 +3,21 @@ 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={navId || 'alert-alerts'} pageNav={pageNav}>
|
||||
<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'),
|
||||
}}
|
||||
>
|
||||
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
|
||||
<TriageScene key={triageScene.state.key} />
|
||||
</UrlSyncContextProvider>
|
||||
|
||||
@@ -3791,7 +3791,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4454,6 +4453,7 @@
|
||||
},
|
||||
"no-properties-changed": "Žádné relevantní vlastnosti se nezměnily",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Datum",
|
||||
"updatedBy": "Aktualizoval uživatel",
|
||||
"version": "Verze"
|
||||
@@ -4912,7 +4912,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Hodnoty oddělené čárkou"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtr názvu",
|
||||
@@ -6010,6 +6011,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Vlastní možnosti",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Hodnoty oddělené čárkou",
|
||||
"selection-options": "Možnosti výběru"
|
||||
},
|
||||
@@ -6601,6 +6605,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Nástěnka byla uložena"
|
||||
},
|
||||
@@ -6624,6 +6633,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6683,8 +6693,11 @@
|
||||
"tooltip-show-usages": "Zobrazit použití"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Náhled hodnot",
|
||||
"show-more": "Zobrazit více"
|
||||
"show-more": "Zobrazit více",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_few": "",
|
||||
"preview-of-values_many": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Keine relevanten Eigenschaften geändert",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Datum",
|
||||
"updatedBy": "Aktualisiert von",
|
||||
"version": "Version"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Werte werden durch Komma getrennt"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Namensfilter",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Benutzerdefinierte Optionen",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Werte werden durch Komma getrennt",
|
||||
"selection-options": "Auswahloptionen"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard gespeichert"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Nutzungen anzeigen"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Vorschau der Werte",
|
||||
"show-more": "Mehr anzeigen"
|
||||
"show-more": "Mehr anzeigen",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "No se ha cambiado ninguna propiedad relevante",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Fecha",
|
||||
"updatedBy": "Actualizada por",
|
||||
"version": "Versión"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valores separados por coma"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Nombrar filtro",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opciones personalizadas",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valores separados por comas",
|
||||
"selection-options": "Opciones de selección"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard guardado"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Mostrar usos"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Vista previa de los valores",
|
||||
"show-more": "Mostrar más"
|
||||
"show-more": "Mostrar más",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Aucune propriété pertinente n’a été modifiée",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Date",
|
||||
"updatedBy": "Mis à jour par",
|
||||
"version": "Version"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valeurs séparées par une virgule"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Nom du filtre",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Personnaliser les options",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valeurs séparées par des virgules",
|
||||
"selection-options": "Options de sélection"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Tableau de bord enregistré"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Afficher les usages"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Aperçu des valeurs",
|
||||
"show-more": "Afficher plus"
|
||||
"show-more": "Afficher plus",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nem változtak meg a releváns tulajdonságok",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Dátum",
|
||||
"updatedBy": "Frissítette:",
|
||||
"version": "Verzió"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Értékek vesszővel elválasztva"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Névszűrő",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Egyéni opciók",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Értékek vesszővel elválasztva",
|
||||
"selection-options": "Kijelölés beállításai"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Irányítópult elmentve"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Használatok megjelenítése"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Értékek előnézete",
|
||||
"show-more": "Több megjelenítése"
|
||||
"show-more": "Több megjelenítése",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "Tidak ada properti yang relevan yang diubah",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Tanggal",
|
||||
"updatedBy": "Diperbarui Oleh",
|
||||
"version": "Versi"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Nilai dipisahkan dengan koma"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filter nama",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opsi kustom",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Nilai dipisahkan dengan koma",
|
||||
"selection-options": "Opsi pemilihan"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dasbor disimpan"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "Tampilkan penggunaan"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Pratinjau nilai",
|
||||
"show-more": "Tampilkan lebih banyak"
|
||||
"show-more": "Tampilkan lebih banyak",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nessuna proprietà rilevante modificata",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Data",
|
||||
"updatedBy": "Aggiornato da",
|
||||
"version": "Versione"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valori separati da virgola"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtro nome",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opzioni personalizzate",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valori separati da virgola",
|
||||
"selection-options": "Seleziona opzioni"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard salvata"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Mostra utilizzi"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Anteprima dei valori",
|
||||
"show-more": "Mostra di più"
|
||||
"show-more": "Mostra di più",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "関連するプロパティは変更されていません",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "日付",
|
||||
"updatedBy": "更新者",
|
||||
"version": "バージョン"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "カンマで区切った値"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "名前フィルター",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "カスタムオプション",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "カンマ区切りの値",
|
||||
"selection-options": "選択オプション"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "ダッシュボードが保存されました"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "使用状況を表示"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "値のプレビュー",
|
||||
"show-more": "さらに表示"
|
||||
"show-more": "さらに表示",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "변경된 관련 속성 없음",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "날짜",
|
||||
"updatedBy": "업데이트한 사용자",
|
||||
"version": "버전"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "쉼표로 구분된 값"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "이름 필터",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "사용자 지정 옵션",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "쉼표로 구분된 값",
|
||||
"selection-options": "선택 옵션"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "대시보드가 저장되었습니다"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "사용처 표시"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "값 미리 보기",
|
||||
"show-more": "더 보기"
|
||||
"show-more": "더 보기",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Geen relevante eigenschappen gewijzigd",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Datum",
|
||||
"updatedBy": "Bijgewerkt door",
|
||||
"version": "Versie"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Waarden gescheiden door komma"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filter een naam geven",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Aangepaste opties",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Waarden gescheiden door komma",
|
||||
"selection-options": "Selectiemogelijkheden"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard opgeslagen"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Gebruik weergeven"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Voorbeeldweergave van waarden",
|
||||
"show-more": "Meer weergeven"
|
||||
"show-more": "Meer weergeven",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3791,7 +3791,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4454,6 +4453,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nie zmieniono istotnych właściwości",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Data",
|
||||
"updatedBy": "Zaktualizowane przez",
|
||||
"version": "Wersja"
|
||||
@@ -4912,7 +4912,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Wartości rozdzielone przecinkami"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtr nazwy",
|
||||
@@ -6010,6 +6011,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opcje niestandardowe",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Wartości rozdzielone przecinkami",
|
||||
"selection-options": "Opcje wyboru"
|
||||
},
|
||||
@@ -6601,6 +6605,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Pulpit został zapisany"
|
||||
},
|
||||
@@ -6624,6 +6633,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6683,8 +6693,11 @@
|
||||
"tooltip-show-usages": "Wyświetl użycie"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Podgląd wartości",
|
||||
"show-more": "Pokaż więcej"
|
||||
"show-more": "Pokaż więcej",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_few": "",
|
||||
"preview-of-values_many": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nenhuma propriedade relevante alterada",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Data",
|
||||
"updatedBy": "Atualizada por",
|
||||
"version": "Versão"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valores separados por vírgula"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtro de nome",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opções personalizadas",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valores separados por vírgula",
|
||||
"selection-options": "Opções de seleção"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Painel de controle salvo"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Exibir usos"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Pré-visualização de valores",
|
||||
"show-more": "Exibir mais"
|
||||
"show-more": "Exibir mais",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Nenhuma propriedade relevante alterada",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Data",
|
||||
"updatedBy": "Atualizado por",
|
||||
"version": "Versão"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Valores separados por vírgulas"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Filtro de nome",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Opções personalizadas",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Valores separados por vírgulas",
|
||||
"selection-options": "Opções de seleção"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Painel de controlo guardado"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Mostrar utilizações"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Pré-visualização de valores",
|
||||
"show-more": "Mostrar mais"
|
||||
"show-more": "Mostrar mais",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3791,7 +3791,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4454,6 +4453,7 @@
|
||||
},
|
||||
"no-properties-changed": "Нет изменений соответствующих свойств",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Дата",
|
||||
"updatedBy": "Обновлено",
|
||||
"version": "Версия"
|
||||
@@ -4912,7 +4912,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Значения, разделенные запятыми"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Фильтр по названию",
|
||||
@@ -6010,6 +6011,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Пользовательские параметры",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Значения, разделенные запятыми",
|
||||
"selection-options": "Параметры выбора"
|
||||
},
|
||||
@@ -6601,6 +6605,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Дашборд сохранен"
|
||||
},
|
||||
@@ -6624,6 +6633,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6683,8 +6693,11 @@
|
||||
"tooltip-show-usages": "Показать варианты использования"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Просмотр значений",
|
||||
"show-more": "Показать еще"
|
||||
"show-more": "Показать еще",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_few": "",
|
||||
"preview-of-values_many": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "Inga relevanta egenskaper har ändrats",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Datum",
|
||||
"updatedBy": "Uppdaterad per",
|
||||
"version": "Version"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Värden åtskilda med kommatecken"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Namnfilter",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Anpassade alternativ",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Värden åtskilda med kommatecken",
|
||||
"selection-options": "Urvalsalternativ"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Kontrollpanelen sparades"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Visa användningar"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Förhandsgranska värden",
|
||||
"show-more": "Visa mer"
|
||||
"show-more": "Visa mer",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "İlgili hiçbir özellik değiştirilmedi",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "Tarih",
|
||||
"updatedBy": "Güncelleyen:",
|
||||
"version": "Sürüm"
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "Virgülle ayrılmış değerler"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Ad filtresi",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Özel seçenekler",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "Virgülle ayrılmış değerler",
|
||||
"selection-options": "Seçim ayarları"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Pano kaydedildi"
|
||||
},
|
||||
@@ -6578,6 +6587,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6637,8 +6647,9 @@
|
||||
"tooltip-show-usages": "Kullanımları göster"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Değerlerin ön izlemesi",
|
||||
"show-more": "Daha fazla göster"
|
||||
"show-more": "Daha fazla göster",
|
||||
"preview-of-values_one": "",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "没有相关属性更改",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "日期",
|
||||
"updatedBy": "更新者",
|
||||
"version": "版本"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "以逗号分隔的值"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "名称筛选器",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "自定义选项",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "以逗号分隔的值",
|
||||
"selection-options": "选择内容选项"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "数据面板已保存"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "显示使用情况"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "值预览",
|
||||
"show-more": "显示更多"
|
||||
"show-more": "显示更多",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
@@ -3743,7 +3743,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "",
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"retry": "",
|
||||
"title": ""
|
||||
@@ -4397,6 +4396,7 @@
|
||||
},
|
||||
"no-properties-changed": "沒有相關的屬性變更",
|
||||
"table": {
|
||||
"notes": "",
|
||||
"updated": "日期",
|
||||
"updatedBy": "更新者",
|
||||
"version": "版本"
|
||||
@@ -4855,7 +4855,8 @@
|
||||
"apply": "",
|
||||
"change-value": "",
|
||||
"discard": "",
|
||||
"modal-title": ""
|
||||
"modal-title": "",
|
||||
"values": "以逗號分隔的值"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "名稱篩選",
|
||||
@@ -5947,6 +5948,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "自訂選項",
|
||||
"json-values-tooltip": "",
|
||||
"name-csv-values": "",
|
||||
"name-json-values": "",
|
||||
"name-values-separated-comma": "以逗號分隔的值",
|
||||
"selection-options": "選擇選項"
|
||||
},
|
||||
@@ -6532,6 +6536,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": ""
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "儀表板已儲存"
|
||||
},
|
||||
@@ -6555,6 +6564,7 @@
|
||||
"label": ""
|
||||
},
|
||||
"hidden": {
|
||||
"description": "",
|
||||
"label": ""
|
||||
},
|
||||
"hidden-label": {
|
||||
@@ -6614,8 +6624,8 @@
|
||||
"tooltip-show-usages": "顯示使用情況"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "數值預覽",
|
||||
"show-more": "顯示更多"
|
||||
"show-more": "顯示更多",
|
||||
"preview-of-values_other": ""
|
||||
},
|
||||
"version-history": {
|
||||
"comparison": {
|
||||
|
||||
Reference in New Issue
Block a user