Compare commits

..

3 Commits

Author SHA1 Message Date
Ryan McKinley 4083cf78d4 default sort 2026-01-08 09:31:50 +03:00
grafana-pr-automation[bot] 97af86efb2 I18n: Download translations from Crowdin (#115968)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-08 00:43:13 +00:00
Paul Marbach f58ab2a6a1 Gauge: Fix endpoint rendering for non-gradient cases (#115910)
* Gauge: Fix endpoint rendering for non-gradient cases

* break out the endpoint markers to its own component with tests
2026-01-07 17:17:35 -05:00
57 changed files with 592 additions and 1961 deletions
-5
View File
@@ -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;
+18 -9
View File
@@ -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
-8
View File
@@ -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.",
-1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
125 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
126 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
127 alertingCentralAlertHistory experimental @grafana/alerting-squad false false false
alertingNavigationV2 experimental @grafana/alerting-squad false false false
128 pluginProxyPreserveTrailingSlash GA @grafana/plugins-platform-backend false false false
129 azureMonitorPrometheusExemplars GA @grafana/partner-datasources false false false
130 authZGRPCServer experimental @grafana/identity-access-team false false false
-4
View File
@@ -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
View File
@@ -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",
+1 -209
View File
@@ -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 ?? '' });
-18
View File
@@ -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,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,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>
);
@@ -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>
+17 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+15 -4
View File
@@ -3759,7 +3759,6 @@
},
"recently-viewed": {
"clear": "",
"empty": "",
"error": "",
"retry": "",
"title": ""
@@ -4416,6 +4415,7 @@
},
"no-properties-changed": "Aucune propriété pertinente na é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": {
+15 -4
View File
@@ -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": {
+14 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+14 -4
View File
@@ -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": {
+14 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+17 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+17 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+15 -4
View File
@@ -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": {
+14 -4
View File
@@ -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": {
+14 -4
View File
@@ -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": {