diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts index 8850cc069fda8..f2d97d7675dcf 100644 --- a/packages/grafana-data/src/themes/createColors.ts +++ b/packages/grafana-data/src/themes/createColors.ts @@ -192,9 +192,9 @@ class LightColors implements ThemeColorsBase> { }; border = { - weak: '#e3e0e7', - medium: '#d0ccd5', - strong: '#bdb9c2', + weak: '#ebe9ee', + medium: '#dcd9e1', + strong: '#c4c0cb', }; secondary = { @@ -228,10 +228,12 @@ class LightColors implements ThemeColorsBase> { text: '#ab6400', }; + // Panels sit on a pure-white surface floating over a soft mauve canvas so the + // card edges read cleanly, matching the Carrot UI light surfaces. background = { - primary: '#faf8fb', - canvas: '#e9e7ed', - secondary: '#fdfdfe', + primary: '#ffffff', + canvas: '#f5f3f7', + secondary: '#faf9fb', }; action = { diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts index 13e7fd46f5ac6..280f2609987d6 100644 --- a/packages/grafana-data/src/themes/createComponents.ts +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -56,9 +56,11 @@ export interface ThemeComponents { } export function createComponents(colors: ThemeColors, shadows: ThemeShadows): ThemeComponents { + // Roomier panel chrome (12px gutters, 40px header) so charts breathe the way + // they do in the Carrot UI / Vercel card language instead of hugging the border. const panel = { - padding: 1, - headerHeight: 4, + padding: 1.5, + headerHeight: 5, background: colors.background.primary, borderColor: colors.border.weak, boxShadow: 'none', diff --git a/packages/grafana-data/src/themes/createShadows.ts b/packages/grafana-data/src/themes/createShadows.ts index d5a2b4b3d69d9..8e380e70ddd06 100644 --- a/packages/grafana-data/src/themes/createShadows.ts +++ b/packages/grafana-data/src/themes/createShadows.ts @@ -12,17 +12,19 @@ export function createShadows(colors: ThemeColors): ThemeShadows { // Shadow base colours are derived from the canvas background of each mode so // they harmonise with the mauve-tinted Carrot palette instead of using raw black. // Dark canvas #121014 → rgb(18, 16, 20) | Light text.primary #211f24 → rgb(33, 31, 36) + // Layered, low-opacity elevation in the Vercel/Geist style: a hairline contact + // shadow stacked with a wider ambient shadow instead of one heavy blur. if (colors.mode === 'dark') { return { - z1: '0px 1px 2px rgba(18, 16, 20, 0.8)', - z2: '0px 4px 8px rgba(18, 16, 20, 0.75)', - z3: '0px 8px 24px rgba(18, 16, 20, 0.9)', + z1: '0px 1px 2px rgba(0, 0, 0, 0.45)', + z2: '0px 2px 4px rgba(0, 0, 0, 0.35), 0px 8px 16px rgba(0, 0, 0, 0.4)', + z3: '0px 4px 8px rgba(0, 0, 0, 0.4), 0px 16px 32px rgba(0, 0, 0, 0.5)', }; } return { - z1: '0px 1px 2px rgba(33, 31, 36, 0.12)', - z2: '0px 4px 8px rgba(33, 31, 36, 0.15)', - z3: '0px 13px 20px 1px rgba(33, 31, 36, 0.12)', + z1: '0px 1px 2px rgba(33, 31, 36, 0.06)', + z2: '0px 1px 2px rgba(33, 31, 36, 0.06), 0px 4px 12px rgba(33, 31, 36, 0.08)', + z3: '0px 2px 4px rgba(33, 31, 36, 0.06), 0px 12px 32px rgba(33, 31, 36, 0.12)', }; } diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts index 35d87ed883448..e7bbddb75270c 100644 --- a/packages/grafana-data/src/themes/createTypography.ts +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -67,10 +67,12 @@ export function createTypography(colors: ThemeColors, typographyInput: ThemeTypo fontFamilyMonospace = defaultFontFamilyMonospace, // The default font size of the Material Specification. fontSize = 14, // px - fontWeightLight = 200, - fontWeightRegular = 300, - fontWeightMedium = 400, - fontWeightBold = 500, + // Geist/Vercel-style weight ramp. The previous 200/300/400/500 ramp rendered + // body copy as Light, which looked washed out against the Carrot UI palette. + fontWeightLight = 300, + fontWeightRegular = 400, + fontWeightMedium = 500, + fontWeightBold = 600, // Tell Grafana-UI what's the font-size on the html element. // 16px is the default font-size used by browsers. htmlFontSize = 16, @@ -111,14 +113,16 @@ export function createTypography(colors: ThemeColors, typographyInput: ThemeTypo // All our fonts/line heights should be integer multiples of 2 to prevent issues with alignment const variants = { - h1: buildVariant(fontWeightRegular, 28, 32, -0.25), - h2: buildVariant(fontWeightRegular, 24, 28, 0), - h3: buildVariant(fontWeightRegular, 22, 24, 0), - h4: buildVariant(fontWeightRegular, 18, 22, 0.25), - h5: buildVariant(fontWeightRegular, 16, 22, 0), - h6: buildVariant(fontWeightMedium, 14, 22, 0.15), - body: buildVariant(fontWeightRegular, fontSize, 22, 0.15), - bodySmall: buildVariant(fontWeightRegular, 12, 18, 0.15), + // Headings use negative tracking (Vercel/Geist convention) and a heavier weight + // so panel titles and section headers read as deliberate UI chrome. + h1: buildVariant(fontWeightBold, 28, 32, -0.6), + h2: buildVariant(fontWeightBold, 24, 28, -0.5), + h3: buildVariant(fontWeightMedium, 22, 24, -0.4), + h4: buildVariant(fontWeightMedium, 18, 22, -0.3), + h5: buildVariant(fontWeightMedium, 16, 22, -0.2), + h6: buildVariant(fontWeightMedium, 14, 22, -0.1), + body: buildVariant(fontWeightRegular, fontSize, 22, 0), + bodySmall: buildVariant(fontWeightRegular, 12, 18, 0), code: { ...buildVariant(fontWeightRegular, 14, 16, 0.15), fontFamily: fontFamilyMonospace }, }; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 35196b97c59c5..355c5bf9f3405 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -55,6 +55,7 @@ "@grafana/faro-web-sdk": "^1.3.6", "@grafana/schema": "11.3.0-pre", "@hello-pangea/dnd": "16.6.0", + "@heroicons/react": "2.2.0", "@leeoniya/ufuzzy": "1.0.14", "@monaco-editor/react": "4.6.0", "@popperjs/core": "2.11.8", diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx index c0cabc1764608..cbd8d644be889 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx @@ -162,7 +162,6 @@ const NarrowScreenForm = (props: FormProps) => { weekStart={weekStart} /> - )} @@ -198,7 +197,6 @@ const FullScreenForm = (props: FormProps) => { weekStart={weekStart} /> - ); }; @@ -254,11 +252,16 @@ const useTimeOption = (raw: RawTimeRange, quickOptions: TimeOption[]): TimeOptio const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRanges, isContainerTall, isFullscreen) => { return { + // Carrot UI popover surface: soft 12px radius, hairline border and layered + // elevation so the panel floats above the dashboard rather than boxing it in. + // NOTE: no `overflow: hidden` here — the time zone / fiscal year selects render + // their menus inline (menuShouldPortal={false}), so clipping the container would + // crop the open dropdown list. Corner bleed is handled by the footer's own radius. container: css({ background: theme.colors.background.primary, boxShadow: theme.shadows.z3, width: `${isFullscreen ? '546px' : '262px'}`, - borderRadius: theme.shape.borderRadius(), + borderRadius: theme.shape.borderRadius(3), border: `1px solid ${theme.colors.border.weak}`, [`${isReversed ? 'left' : 'right'}`]: 0, }), @@ -286,7 +289,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRang flexDirection: 'column', }), timeRangeFilter: css({ - padding: theme.spacing(1), + padding: theme.spacing(1.5), + borderBottom: `1px solid ${theme.colors.border.weak}`, }), spacing: css({ marginTop: '16px', @@ -325,12 +329,11 @@ const getNarrowScreenStyles = (theme: GrafanaTheme2) => ({ const getFullScreenStyles = (theme: GrafanaTheme2, hideQuickRanges?: boolean) => ({ container: css({ - paddingTop: '9px', - paddingLeft: '11px', - paddingRight: !hideQuickRanges ? '20%' : '11px', + padding: theme.spacing(1.5), + paddingRight: !hideQuickRanges ? '20%' : theme.spacing(1.5), }), title: css({ - marginBottom: '11px', + marginBottom: theme.spacing(1.5), }), recent: css({ flexGrow: 1, diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx index 6fe7b058b7f00..bb94d26940587 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx @@ -169,26 +169,39 @@ export const TimePickerFooter = (props: Props) => { const getStyle = stylesFactory((theme: GrafanaTheme2) => { return { + // Footer reads as a distinct utility bar: recessed surface, hairline top rule. + // `:last-child` keeps the rounding on whichever block actually ends the popover + // (this bar when collapsed, the edit panel when the settings are expanded). container: css({ borderTop: `1px solid ${theme.colors.border.weak}`, - padding: '11px', + background: theme.colors.background.secondary, + padding: theme.spacing(1.5), display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - fontSize: '14px', + fontSize: theme.typography.bodySmall.fontSize, lineHeight: '20px', + '&:last-child': { + borderBottomLeftRadius: theme.shape.borderRadius(3), + borderBottomRightRadius: theme.shape.borderRadius(3), + }, '& button': { - borderRadius: '6px', + borderRadius: theme.shape.radius.default, }, }), editContainer: css({ borderTop: `1px solid ${theme.colors.border.weak}`, - padding: '11px', + background: theme.colors.background.secondary, + padding: theme.spacing(1.5), justifyContent: 'space-between', alignItems: 'center', - fontSize: '14px', + fontSize: theme.typography.bodySmall.fontSize, lineHeight: '20px', + // Round the trailing corners to match the popover instead of relying on the + // parent clipping, which would crop the inline select menus. + borderBottomLeftRadius: theme.shape.borderRadius(3), + borderBottomRightRadius: theme.shape.borderRadius(3), }), spacer: css({ marginLeft: '7px', @@ -216,9 +229,22 @@ const getStyle = stylesFactory((theme: GrafanaTheme2) => { // orange brand gradient) with a neutral gray for the time-zone / // fiscal-year tabs inside the time-range picker only. tabsOverride: css({ - 'button[role="tab"][aria-selected="true"]::before, a[role="tab"][aria-selected="true"]::before': { + '[role="tab"][aria-selected="true"]': { + // The shared Tab sets `overflow: hidden` on the active state, which clips + // its own rounded underline into a boxed outline around the tab. Reset it + // so only the underline shows. + overflow: 'visible', + border: 'none', + boxShadow: 'none', + }, + + '[role="tab"][aria-selected="true"]::before': { backgroundImage: 'none', - backgroundColor: theme.colors.border.strong, + backgroundColor: theme.colors.text.primary, + // A slim square-cut rule reads as an underline; the inherited 4px/6px-radius + // bar looked like a stray border sitting under the label. + height: '2px', + borderRadius: 0, }, }), }; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx index e65201002bb50..4f39f03fba5b8 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx @@ -7,10 +7,14 @@ import { useStyles2 } from '../../../themes'; const getStyles = (theme: GrafanaTheme2) => { return { + // Section labels read as small-caps eyebrows (Carrot UI convention) so the + // quick-range values below them stay the dominant text in the popover. text: css({ - fontSize: theme.typography.size.md, + fontSize: theme.typography.size.xs, fontWeight: theme.typography.fontWeightMedium, - color: theme.colors.text.primary, + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: theme.colors.text.secondary, margin: 0, display: 'flex', }), diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx index cfc070c51c791..06e2c56b18fce 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { useRef, ReactNode } from 'react'; -import { TimeOption } from '@grafana/data'; +import { GrafanaTheme2, TimeOption } from '@grafana/data'; import { useStyles2 } from '../../../themes'; import { t } from '../../../utils/i18n'; @@ -82,12 +82,12 @@ function isEqual(x: TimeOption, y?: TimeOption): boolean { return y.from === x.from && y.to === x.to; } -const getStyles = () => ({ +const getStyles = (theme: GrafanaTheme2) => ({ title: css({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '8px 16px 5px 9px', + padding: theme.spacing(1.5, 2, 0.75, 2), }), }); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx index 5f00cd4c17f31..5e61249c5b97e 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx @@ -9,16 +9,23 @@ import { getFocusStyles } from '../../../themes/mixins'; const getStyles = (theme: GrafanaTheme2) => { return { + // Carrot UI list rows: inset pills with a rounded hover/selected surface + // instead of full-bleed bars butting against the popover edges. container: css({ display: 'flex', alignItems: 'center', flexDirection: 'row-reverse', justifyContent: 'space-between', + padding: theme.spacing(0, 1), }), + // Selected uses the neutral secondary surface rather than the brand orange: + // an orange fill fights the panel accents and hurts label contrast. selected: css({ - background: theme.colors.secondary.main, - color: theme.colors.secondary.text, - fontWeight: theme.typography.fontWeightMedium, + '& label, & label:hover': { + background: theme.colors.secondary.main, + color: theme.colors.text.primary, + fontWeight: theme.typography.fontWeightMedium, + }, }), radio: css({ opacity: 0, @@ -30,11 +37,17 @@ const getStyles = (theme: GrafanaTheme2) => { cursor: 'pointer', flex: 1, padding: theme.spacing(0.75, 1.25), - fontSize: '14px', + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, lineHeight: '20px', + color: theme.colors.text.secondary, + transition: theme.transitions.create(['background-color', 'color'], { + duration: theme.transitions.duration.shortest, + }), '&:hover': { background: theme.colors.action.hover, + color: theme.colors.text.primary, cursor: 'pointer', }, }), diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index eba75145d57a5..9e54f50963a4f 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -295,7 +295,11 @@ export function PanelChrome({ )} {hasHeader && ( -
+
{statusMessage && (
@@ -387,12 +391,16 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { label: 'panel-container', backgroundColor: background, border: `1px solid ${borderColor}`, - borderRadius: theme.shape.borderRadius(2), + // Larger corner radius + soft elevation to match the Carrot UI card language. + borderRadius: theme.shape.borderRadius(3), boxShadow: theme.shadows.z1, position: 'relative', height: '100%', display: 'flex', flexDirection: 'column', + transition: theme.transitions.create(['box-shadow', 'border-color'], { + duration: theme.transitions.duration.short, + }), '.show-on-hover': { opacity: '0', @@ -400,6 +408,7 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { }, '&:hover': { + borderColor: theme.colors.border.medium, boxShadow: theme.shadows.z2, // only show menu icon on hover '.show-on-hover': { @@ -461,6 +470,13 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { display: 'flex', alignItems: 'center', }), + // Hairline separator between the title row and the visualisation, the way + // Vercel-style cards split header from body. Only applied when the panel + // actually renders a title, so untitled panels stay borderless. + headerDivider: css({ + label: 'panel-header-divider', + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), pointer: css({ cursor: 'pointer', }), @@ -476,8 +492,15 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { title: css({ label: 'panel-title', display: 'flex', + alignItems: 'center', padding: theme.spacing(0, padding), minWidth: 0, + // Panel titles are secondary chrome: slightly muted, tighter tracking. + '& h2': { + color: theme.colors.text.primary, + fontWeight: theme.typography.fontWeightMedium, + letterSpacing: '-0.01em', + }, // FN-dashboard panel titles span the full chrome width so the title // and the right-aligned menu line up edge-to-edge. Titles render in // normal case — the uppercase transform that previously lived here diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx index b9e74f5c39d15..121c21d60f8e6 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { Icon } from '../Icon/Icon'; import { Tooltip } from '../Tooltip'; import { TitleItem } from './TitleItem'; @@ -30,7 +30,8 @@ export function PanelDescription({ description, className }: Props) { return description !== '' ? ( - + {/* Heroicons is the icon set used by the CodeRabbit UI (Carrot UI) design system. */} + ) : null; @@ -48,5 +49,18 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'block', }, }), + icon: css({ + width: 16, + height: 16, + flexShrink: 0, + color: theme.colors.text.secondary, + transition: theme.transitions.create('color', { + duration: theme.transitions.duration.shortest, + }), + + '&:hover': { + color: theme.colors.text.primary, + }, + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx index a4922f2331b8a..9fd9989a69396 100644 --- a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx @@ -39,7 +39,15 @@ export const TitleItem = forwardRef( ); } else if (onClick) { return ( - ); diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 3e80ac14305f9..7c9e986885505 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -175,6 +175,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response FolderTitle: "General", AnnotationsPermissions: annotationPermissions, PublicDashboardEnabled: publicDashboardEnabled, + WorkspaceID: dash.WorkspaceID, } metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc() // lookup folder title diff --git a/pkg/api/dashboard_mfe_mask.go b/pkg/api/dashboard_mfe_mask.go index 0b3a07d71e630..6befee6dca924 100644 --- a/pkg/api/dashboard_mfe_mask.go +++ b/pkg/api/dashboard_mfe_mask.go @@ -190,7 +190,25 @@ func maskTemplatingList(list *simplejson.Json) { func maskRawQueryFields(obj *simplejson.Json, mask string) { for _, field := range mfeMaskedQueryFields { if cur, ok := obj.CheckGet(field); ok { - if _, err := cur.String(); err == nil { + if s, err := cur.String(); err == nil { + // An empty query field has nothing to hide, and masking it is + // actively harmful: it fabricates a `[MFE_REDACTED:p::]` + // marker that promises the proxy a query which does not exist. The + // proxy then looks the panel up, finds an empty `rawSql`, and fails + // the whole batch closed rather than rendering the panel. + // + // Two shapes rely on an intentionally empty query field: + // - CodeRabbit reporting-backed panels, whose data comes from the + // reporting API via a `crReportingTag` on the target rather than + // from SQL. + // - Variable metricFindQueries, which the frontend already sends + // with an empty rawSql and a `tempVar` refId. + // + // Leaving the empty string untouched is safe by construction: there + // is no query text to leak. + if strings.TrimSpace(s) == "" { + continue + } obj.Set(field, mask) } } diff --git a/pkg/api/dashboard_mfe_mask_test.go b/pkg/api/dashboard_mfe_mask_test.go index bc97267a64d18..e1741872f69e7 100644 --- a/pkg/api/dashboard_mfe_mask_test.go +++ b/pkg/api/dashboard_mfe_mask_test.go @@ -260,6 +260,72 @@ func TestMaskDashboardQueriesForMFE(t *testing.T) { // The proxy decodes each segment with `decodeURIComponent`, which preserves // `+` literally — so emitting `+` for space here would break the round-trip // for refIDs / variable names containing spaces. +func TestMaskDashboardQueriesLeavesEmptyQueryFields(t *testing.T) { + // Regression: masking an empty rawSql fabricated a redaction marker that + // promised the CodeRabbit proxy a query which does not exist. The proxy + // resolved the panel, found no SQL, and failed the whole /api/ds/query batch + // with "Failed to resolve FN-redacted query". + t.Run("leaves an empty rawSql untouched", func(t *testing.T) { + raw := []byte(`{ + "panels": [ + { + "id": 4, + "targets": [ + { + "refId": "A", + "rawSql": "", + "crReportingTag": "[CR_REPORT:abc]" + } + ] + } + ] + }`) + data, err := simplejson.NewJson(raw) + require.NoError(t, err) + + maskDashboardQueriesForMFE(data) + + target := data.Get("panels").GetIndex(0).Get("targets").GetIndex(0) + require.Equal(t, "", target.Get("rawSql").MustString()) + // The reporting tag is not a masked query field and must survive intact. + require.Equal(t, "[CR_REPORT:abc]", target.Get("crReportingTag").MustString()) + }) + + t.Run("leaves a whitespace-only query untouched", func(t *testing.T) { + raw := []byte(`{ + "panels": [ + {"id": 7, "targets": [{"refId": "A", "rawSql": " "}]} + ] + }`) + data, err := simplejson.NewJson(raw) + require.NoError(t, err) + + maskDashboardQueriesForMFE(data) + + target := data.Get("panels").GetIndex(0).Get("targets").GetIndex(0) + require.Equal(t, " ", target.Get("rawSql").MustString()) + }) + + t.Run("still masks a non-empty query on the same dashboard", func(t *testing.T) { + raw := []byte(`{ + "panels": [ + {"id": 4, "targets": [{"refId": "A", "rawSql": ""}]}, + {"id": 5, "targets": [{"refId": "A", "rawSql": "SELECT 1"}]} + ] + }`) + data, err := simplejson.NewJson(raw) + require.NoError(t, err) + + maskDashboardQueriesForMFE(data) + + panels := data.Get("panels") + require.Equal(t, "", panels.GetIndex(0).Get("targets").GetIndex(0).Get("rawSql").MustString()) + require.Equal(t, + "[MFE_REDACTED:p:5:A]", + panels.GetIndex(1).Get("targets").GetIndex(0).Get("rawSql").MustString()) + }) +} + func TestMfeEncodeMaskSegment(t *testing.T) { cases := map[string]string{ "": "", diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 66a8922a4f1a3..dfe2f631fb684 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -36,6 +36,9 @@ type DashboardMeta struct { AnnotationsPermissions *dashboardsV0.AnnotationPermission `json:"annotationsPermissions"` PublicDashboardEnabled bool `json:"publicDashboardEnabled,omitempty"` HasPublicDashboard bool `json:"hasPublicDashboard,omitempty"` + // WorkspaceID scopes a dashboard to a CodeRabbit workspace. Empty for + // dashboards provisioned outside the micro frontend flow. + WorkspaceID string `json:"workspaceId,omitempty"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/search.go b/pkg/api/search.go index 13c76fabf4f27..4c238f8ede70d 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -96,6 +96,9 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { FolderUIDs: folderUIDs, Permission: permission, Sort: sort, + // CodeRabbit MFE: scopes AI-generated custom dashboards to the calling + // product workspace. Ignored when absent. + WorkspaceID: c.Query("workspaceId"), } hits, err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 8e91674cb3543..07beab1c9da4f 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -850,6 +850,10 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F filters = append(filters, searchstore.TagsFilter{Tags: query.Tags}) } + if query.WorkspaceID != "" { + filters = append(filters, searchstore.WorkspaceFilter{WorkspaceID: query.WorkspaceID}) + } + if len(query.DashboardUIDs) > 0 { filters = append(filters, searchstore.DashboardFilter{UIDs: query.DashboardUIDs}) } else if len(query.DashboardIds) > 0 { diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 9428fd961e55d..261867d348c57 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -48,6 +48,11 @@ type Dashboard struct { Title string Data *simplejson.Json + + // WorkspaceID is the CodeRabbit product workspace that owns this dashboard. + // Only set for AI-generated custom dashboards saved through the MFE; empty + // for every dashboard provisioned the normal Grafana way. + WorkspaceID string `xorm:"workspace_id"` } func (d *Dashboard) SetID(id int64) { @@ -136,6 +141,7 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash.UpdatedBy = userID dash.OrgID = cmd.OrgID + dash.WorkspaceID = cmd.WorkspaceID dash.PluginID = cmd.PluginID dash.IsFolder = cmd.IsFolder metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() @@ -207,6 +213,11 @@ type SaveDashboardCommand struct { FolderUID string `json:"folderUid" xorm:"folder_uid"` IsFolder bool `json:"isFolder"` + // WorkspaceID scopes an AI-generated custom dashboard to a CodeRabbit + // product workspace. Accepted from the save payload so the handler can + // persist it without a second write. + WorkspaceID string `json:"workspaceId" xorm:"workspace_id"` + UpdatedAt time.Time } @@ -416,11 +427,15 @@ type FindPersistedDashboardsQuery struct { FolderIds []int64 FolderUIDs []string Tags []string - Limit int64 - Page int64 - Permission dashboardaccess.PermissionType - Sort model.SortOption - IsDeleted bool + // WorkspaceID restricts results to dashboards owned by a single CodeRabbit + // product workspace. Empty means "no workspace filter" and preserves the + // stock Grafana search behaviour. + WorkspaceID string + Limit int64 + Page int64 + Permission dashboardaccess.PermissionType + Sort model.SortOption + IsDeleted bool Filters []any } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index f1e40b2f98a89..0a57a326daff6 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -217,15 +217,16 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() cmd := &dashboards.SaveDashboardCommand{ - Dashboard: dash.Data, - Message: dto.Message, - OrgID: dto.OrgID, - Overwrite: dto.Overwrite, - UserID: userID, - FolderID: dash.FolderID, // nolint:staticcheck - FolderUID: dash.FolderUID, - IsFolder: dash.IsFolder, - PluginID: dash.PluginID, + Dashboard: dash.Data, + Message: dto.Message, + OrgID: dto.OrgID, + Overwrite: dto.Overwrite, + UserID: userID, + FolderID: dash.FolderID, // nolint:staticcheck + FolderUID: dash.FolderUID, + IsFolder: dash.IsFolder, + PluginID: dash.PluginID, + WorkspaceID: dash.WorkspaceID, } if !dto.UpdatedAt.IsZero() { diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index 74c04f525cf73..42ec1be15b4da 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -48,6 +48,8 @@ type Query struct { FolderUIDs []string Permission dashboardaccess.PermissionType Sort string + // WorkspaceID scopes results to a single CodeRabbit product workspace. + WorkspaceID string } type Service interface { @@ -101,6 +103,7 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model. Page: query.Page, Permission: query.Permission, IsDeleted: query.IsDeleted, + WorkspaceID: query.WorkspaceID, } if sortOpt, exists := s.sortOptions[query.Sort]; exists { diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index bf19b56277d01..713754d2f9ed7 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -239,6 +239,19 @@ func addDashboardMigration(mg *Migrator) { Name: "deleted", Type: DB_DateTime, Nullable: true, })) + // CodeRabbit MFE: AI-generated custom dashboards are provisioned per + // product workspace rather than per Grafana org, so the owning workspace is + // stored alongside the dashboard and used to scope list/read access. + // Nullable so every pre-existing dashboard row stays valid. + mg.AddMigration("Add workspace_id for dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "workspace_id", Type: DB_NVarchar, Length: 190, Nullable: true, + })) + + mg.AddMigration("Add index for dashboard workspace_id", NewAddIndexMigration(dashboardV2, &Index{ + Cols: []string{"workspace_id"}, + Type: IndexType, + })) + mg.AddMigration("Add index for deleted", NewAddIndexMigration(dashboardV2, &Index{ Cols: []string{"deleted"}, Type: IndexType, diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index 432ac9e4a4d97..2a8ed5167266f 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -219,3 +219,16 @@ func (f DeletedFilter) Where() (string, []any) { return "dashboard.deleted IS NULL", nil } + +// WorkspaceFilter restricts results to dashboards owned by a single CodeRabbit +// product workspace. Applied only when a workspace id is supplied, so stock +// Grafana search behaviour is unchanged for every other caller. +type WorkspaceFilter struct { + WorkspaceID string +} + +var _ model.FilterWhere = WorkspaceFilter{} + +func (f WorkspaceFilter) Where() (string, []any) { + return "dashboard.workspace_id = ?", []any{f.WorkspaceID} +} diff --git a/pkg/services/sqlstore/searchstore/filters_test.go b/pkg/services/sqlstore/searchstore/filters_test.go index 6a8f943b4f2da..3293c4276b149 100644 --- a/pkg/services/sqlstore/searchstore/filters_test.go +++ b/pkg/services/sqlstore/searchstore/filters_test.go @@ -57,3 +57,12 @@ func TestFolderUIDFilter(t *testing.T) { }) } } + +func TestWorkspaceFilter(t *testing.T) { + f := searchstore.WorkspaceFilter{WorkspaceID: "ws-123"} + + sql, params := f.Where() + + assert.Equal(t, "dashboard.workspace_id = ?", sql) + assert.Equal(t, []any{"ws-123"}, params) +} diff --git a/public/app/core/reducers/fn-slice.test.ts b/public/app/core/reducers/fn-slice.test.ts new file mode 100644 index 0000000000000..ce22d930515c2 --- /dev/null +++ b/public/app/core/reducers/fn-slice.test.ts @@ -0,0 +1,7 @@ +import { fnStateProps } from './fn-slice'; + +describe('fn-slice', () => { + it('includes the host portal container id in copied microfrontend state props', () => { + expect(fnStateProps).toContain('portalContainerID'); + }); +}); diff --git a/public/app/core/reducers/fn-slice.ts b/public/app/core/reducers/fn-slice.ts index 0c885e2c770a0..7dd50ce334402 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -4,18 +4,33 @@ import { GrafanaThemeType } from '@grafana/data'; import { AnyObject } from '../../fn-app/types'; +export interface FnPanelOptionsUpdate { + readonly options: Readonly>; + readonly panelId: number; + readonly revision: number; +} + export interface FnState { uid: string; slug: string; version: number; controlsContainer: string | null; + dashboardAccessMode: 'standard' | 'custom'; pageTitle: string; queryParams: AnyObject; hiddenVariables: string[]; + /** + * When true, each panel header renders an edit affordance that reports the + * clicked panel back to the host through `metadata.eventListener`. The host + * owns the editing UI, so Grafana only surfaces the trigger. + */ + enablePanelEdit: boolean; + enablePanelLayoutEdit: boolean; metadata: { teams: string[]; eventListener: ((event: { type: string; data: T }) => void) | null; }; + panelOptionsUpdate?: FnPanelOptionsUpdate; portalContainerID: string; } @@ -25,7 +40,19 @@ export type SetFnStateAction = PayloadAction; export type FnStateProp = keyof FnState; @@ -33,8 +60,13 @@ export type FnPropsMappedFromState = Pick; export const fnStateProps: FnStateProp[] = [ 'controlsContainer', + 'dashboardAccessMode', + 'enablePanelEdit', + 'enablePanelLayoutEdit', 'hiddenVariables', 'pageTitle', + 'panelOptionsUpdate', + 'portalContainerID', 'queryParams', 'slug', 'uid', @@ -51,13 +83,17 @@ export const INITIAL_FN_STATE: FnState = { slug: '', version: 1, controlsContainer: null, + dashboardAccessMode: 'standard', pageTitle: '', queryParams: {}, hiddenVariables: [], + enablePanelEdit: false, + enablePanelLayoutEdit: false, metadata: { teams: [], eventListener: null, }, + panelOptionsUpdate: undefined, portalContainerID: 'grafana-portal', } as const; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx new file mode 100644 index 0000000000000..b7c8a105414af --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -0,0 +1,201 @@ +import type { FieldConfigSource } from '@grafana/data'; +import type { PanelModel } from 'app/features/dashboard/state'; + +import { applyFnPanelOptionsPreview } from './DashboardPageFnPanelOptions'; + +interface PanelStub { + description?: string; + fieldConfig: FieldConfigSource; + id: number; + options: Record; + render: jest.Mock; + title: string; + type: string; + updateFieldConfig: jest.Mock; + updateOptions: jest.Mock]>; +} + +function getPanel( + type: string, + panelOverrides: Partial> = {} +): PanelModel & PanelStub { + const panel: PanelStub = { + fieldConfig: { defaults: {}, overrides: [] }, + id: 1, + options: {}, + render: jest.fn(), + title: 'Panel', + type, + updateFieldConfig: jest.fn(), + updateOptions: jest.fn]>(), + ...panelOverrides, + }; + + panel.updateFieldConfig.mockImplementation((fieldConfig) => { + panel.fieldConfig = fieldConfig; + }); + panel.updateOptions.mockImplementation((options) => { + panel.options = options; + }); + + return panel as PanelModel & PanelStub; +} + +function applyOptions(panel: PanelModel, options: Record): void { + applyFnPanelOptionsPreview(panel, { + options, + panelId: panel.id, + revision: 1, + }); +} + +describe('applyFnPanelOptionsPreview', () => { + it('applies common field config and stat text options', () => { + const panel = getPanel('stat', { + options: { text: { valueSize: 18 } }, + }); + + applyOptions(panel, { + decimals: 2, + description: 'Updated description', + fontSize: 36, + title: 'Updated title', + unit: 'suffix:s', + }); + + expect(panel.title).toBe('Updated title'); + expect(panel.description).toBe('Updated description'); + expect(panel.fieldConfig.defaults).toEqual( + expect.objectContaining({ + decimals: 2, + unit: 'suffix:s', + }) + ); + expect(panel.options).toEqual(expect.objectContaining({ text: { valueSize: 36 } })); + }); + + it('applies table pagination, filters, sort, header, and footer options', () => { + const panel = getPanel('table'); + + applyOptions(panel, { + tableCellHeight: 'lg', + tableColumnFilter: true, + tableFooter: true, + tablePagination: true, + tableShowHeader: false, + tableSortBy: 'Author', + tableSortDesc: true, + }); + + expect(panel.fieldConfig.defaults.custom).toEqual({ filterable: true }); + expect(panel.options).toEqual( + expect.objectContaining({ + cellHeight: 'lg', + footer: expect.objectContaining({ + enablePagination: true, + show: true, + }), + showHeader: false, + sortBy: [{ desc: true, displayName: 'Author' }], + }) + ); + }); + + it('applies timeseries style, stacking, legend, and tooltip options', () => { + const panel = getPanel('timeseries', { + fieldConfig: { + defaults: { custom: { stacking: { group: 'B', mode: 'none' } } }, + overrides: [], + }, + options: { legend: { displayMode: 'list', placement: 'bottom', showLegend: true } }, + }); + + applyOptions(panel, { + legend: false, + legendMode: 'table', + legendPlacement: 'right', + timeseriesDrawStyle: 'bars', + timeseriesFillOpacity: 30, + timeseriesLineInterpolation: 'smooth', + timeseriesLineWidth: 3, + timeseriesPointSize: 8, + timeseriesShowPoints: 'always', + timeseriesStacking: 'normal', + tooltipMode: 'multi', + tooltipSort: 'desc', + }); + + expect(panel.fieldConfig.defaults.custom).toEqual( + expect.objectContaining({ + drawStyle: 'bars', + fillOpacity: 30, + lineInterpolation: 'smooth', + lineWidth: 3, + pointSize: 8, + showPoints: 'always', + stacking: { group: 'B', mode: 'normal' }, + }) + ); + expect(panel.options.legend).toEqual( + expect.objectContaining({ + displayMode: 'table', + placement: 'right', + showLegend: false, + }) + ); + expect(panel.options.tooltip).toEqual({ mode: 'multi', sort: 'desc' }); + }); + + it('applies bar chart layout and display options', () => { + const panel = getPanel('barchart'); + + applyOptions(panel, { + barFillOpacity: 75, + barGroupWidth: 0.6, + barRadius: 0.2, + barShowValue: 'always', + barStacking: 'percent', + barTickLabelMaxLength: 18, + barTickLabelRotation: -30, + barWidth: 0.8, + orientation: 'horizontal', + }); + + expect(panel.fieldConfig.defaults.custom).toEqual({ fillOpacity: 75 }); + expect(panel.options).toEqual( + expect.objectContaining({ + barRadius: 0.2, + barWidth: 0.8, + groupWidth: 0.6, + orientation: 'horizontal', + showValue: 'always', + stacking: 'percent', + xTickLabelMaxLength: 18, + xTickLabelRotation: -30, + }) + ); + }); + + it('applies pie chart labels, legend values, and tooltip options', () => { + const panel = getPanel('piechart'); + + applyOptions(panel, { + pieDisplayLabels: ['name', 'percent'], + pieLegendValues: ['value'], + pieType: 'donut', + tooltipMode: 'none', + tooltipSort: 'none', + }); + + expect(panel.options).toEqual( + expect.objectContaining({ + displayLabels: ['name', 'percent'], + legend: expect.objectContaining({ + values: ['value'], + }), + pieType: 'donut', + tooltip: { mode: 'none', sort: 'none' }, + }) + ); + }); +}); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 0b5e65fde62bf..3d97d3ab84f53 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -14,13 +14,14 @@ import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaCont import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { FnGlobalState } from 'app/core/reducers/fn-slice'; +import { FnGlobalState, FnPanelOptionsUpdate } from 'app/core/reducers/fn-slice'; import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage'; import { FNDashboardProps } from 'app/fn-app/types'; +import { FnLoggerService } from 'app/fn_logger'; import { DashboardRoutes, DashboardState, KioskMode, StoreState } from 'app/types'; import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events'; @@ -44,6 +45,10 @@ import { cleanUpDashboardAndVariables } from '../state/actions'; import { initDashboard } from '../state/initDashboard'; import { calculateNewPanelGridPos } from '../utils/panel'; +import { applyFnPanelOptionsPreview } from './DashboardPageFnPanelOptions'; + +export { applyFnPanelOptionsPreview } from './DashboardPageFnPanelOptions'; + export interface DashboardPageRouteParams { uid?: string; type?: string; @@ -72,7 +77,12 @@ export type MapStateToDashboardPageProps = MapStateToProps< Pick & { dashboard: ReturnType; navIndex: StoreState['navIndex']; - } & Pick, + } & Pick< + FnGlobalState, + 'FNDashboard' | 'controlsContainer' | 'dashboardAccessMode' | 'enablePanelLayoutEdit' | 'panelOptionsUpdate' + > & { + dashboardEventListener: FnGlobalState['metadata']['eventListener']; + }, OwnProps, StoreState >; @@ -94,6 +104,10 @@ export const mapStateToProps: MapStateToDashboardPageProps = (state) => ({ navIndex: state.navIndex, FNDashboard: state.fnGlobalState.FNDashboard, controlsContainer: state.fnGlobalState.controlsContainer, + dashboardAccessMode: state.fnGlobalState.dashboardAccessMode, + enablePanelLayoutEdit: state.fnGlobalState.enablePanelLayoutEdit, + panelOptionsUpdate: state.fnGlobalState.panelOptionsUpdate, + dashboardEventListener: state.fnGlobalState.metadata?.eventListener ?? null, }); const mapDispatchToProps: MapDispatchToDashboardPageProps = { @@ -196,6 +210,16 @@ export class UnthemedDashboardPage extends PureComponent { return; } + if ( + FNDashboard && + this.props.panelOptionsUpdate && + (prevProps.dashboard !== this.props.dashboard || + prevProps.panelOptionsUpdate?.panelId !== this.props.panelOptionsUpdate.panelId || + prevProps.panelOptionsUpdate?.revision !== this.props.panelOptionsUpdate.revision) + ) { + this.applyFnPanelOptionsUpdate(this.props.panelOptionsUpdate); + } + if (!FNDashboard) { const routeReloadCounter = (this.props.history.location?.state as any)?.routeReloadCounter; @@ -257,6 +281,20 @@ export class UnthemedDashboardPage extends PureComponent { } } + applyFnPanelOptionsUpdate(update: FnPanelOptionsUpdate) { + const panel = this.props.dashboard?.getPanelById(update.panelId); + if (!panel) { + FnLoggerService.warn('Unable to apply FN panel options update because the panel was not found', { + panelId: update.panelId, + revision: update.revision, + uid: this.props.dashboard?.uid, + }); + return; + } + + applyFnPanelOptionsPreview(panel, update); + } + updateLiveTimer = () => { let tr: TimeRange | undefined = undefined; if (this.props.dashboard?.liveNow) { @@ -356,6 +394,32 @@ export class UnthemedDashboardPage extends PureComponent { this.setState({ scrollElement }); }; + getFnDashboardSaveModel() { + return this.props.dashboard?.getSaveModelClone(); + } + + emitFnDashboardEvent(type: string, data: T): void { + const listener = this.props.dashboardEventListener; + if (!listener) { + return; + } + + try { + listener({ type, data }); + } catch (error) { + FnLoggerService.warn('FN dashboard event listener failed', { error, type }); + } + } + + onFnDashboardLayoutChange = () => { + const dashboardJson = this.getFnDashboardSaveModel(); + if (!dashboardJson) { + return; + } + + this.emitFnDashboardEvent('dashboardLayoutChanged', dashboardJson); + }; + getInspectPanel() { const { dashboard, queryParams } = this.props; @@ -376,7 +440,15 @@ export class UnthemedDashboardPage extends PureComponent { } render() { - const { dashboard, initError, queryParams, FNDashboard, controlsContainer } = this.props; + const { + dashboard, + initError, + queryParams, + FNDashboard, + controlsContainer, + dashboardAccessMode, + enablePanelLayoutEdit, + } = this.props; const { editPanel, viewPanel, pageNav, sectionNav } = this.state; const kioskMode = getKioskMode(this.props.queryParams); @@ -390,6 +462,19 @@ export class UnthemedDashboardPage extends PureComponent { const showSubMenu = !editPanel && !kioskMode && !this.props.queryParams.editview; const showToolbar = FNDashboard || (kioskMode !== KioskMode.Full && !queryParams.editview); + const isCustomFnDashboardLayoutEditable = + FNDashboard && dashboardAccessMode === 'custom' && enablePanelLayoutEdit && !viewPanel && !editPanel; + const isDashboardGridLayoutEditable = FNDashboard + ? isCustomFnDashboardLayoutEditable + : Boolean(dashboard.meta.canEdit); + const fnControlsClassName = cx( + 'flex w-full gap-y-2', + viewPanel + ? 'flex-row items-start justify-between gap-x-3' + : 'flex-col-reverse md:flex-row md:items-center md:justify-between' + ); + const fnVariablesClassName = cx('flex items-center', viewPanel ? 'min-w-0 flex-1' : 'w-full'); + const fnTimeRangeClassName = cx('flex items-center justify-end gap-2', viewPanel ? 'shrink-0' : 'w-full'); const pageClassName = cx({ 'panel-in-fullscreen': Boolean(viewPanel), @@ -455,15 +540,15 @@ export class UnthemedDashboardPage extends PureComponent { {!FNDashboard && } {initError && } {FNDashboard && ( -
-
+
+
{showSubMenu && (
)}
-
{FNTimeRange}
+
{FNTimeRange}
)} {showSubMenu && !FNDashboard && ( @@ -474,6 +559,8 @@ export class UnthemedDashboardPage extends PureComponent { diff --git a/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts b/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts new file mode 100644 index 0000000000000..faa9b33d76fab --- /dev/null +++ b/public/app/features/dashboard/containers/DashboardPageFnPanelOptions.ts @@ -0,0 +1,322 @@ +import type { FieldConfigSource } from '@grafana/data'; +import type { FnPanelOptionsUpdate } from 'app/core/reducers/fn-slice'; +import type { PanelModel } from 'app/features/dashboard/state'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function stringArrayValue(value: unknown): string[] | undefined { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined; +} + +interface PanelFieldConfigParts { + readonly custom: Record; + readonly defaults: Record; + readonly fieldConfig: FieldConfigSource; +} + +const EMPTY_FIELD_CONFIG: FieldConfigSource = { + defaults: {}, + overrides: [], +}; + +function panelFieldConfigParts(panel: PanelModel): PanelFieldConfigParts { + const baseFieldConfig = panel.fieldConfig ?? EMPTY_FIELD_CONFIG; + const defaults = isRecord(baseFieldConfig.defaults) ? { ...baseFieldConfig.defaults } : {}; + const custom = isRecord(defaults.custom) ? { ...defaults.custom } : {}; + const fieldConfig: FieldConfigSource = { + ...baseFieldConfig, + defaults, + overrides: baseFieldConfig.overrides ?? [], + }; + + return { + custom, + defaults, + fieldConfig, + }; +} + +function supportsLegendAndTooltip(panel: PanelModel): boolean { + return panel.type === 'timeseries' || panel.type === 'barchart' || panel.type === 'piechart'; +} + +function applyLegendOptions( + panel: PanelModel, + options: Record, + draft: Readonly> +): void { + const legend: Record = isRecord(options.legend) + ? { ...options.legend } + : { calcs: [], displayMode: 'list', placement: 'bottom' }; + + const legendVisible = booleanValue(draft.legend); + if (legendVisible !== undefined) { + legend.showLegend = legendVisible; + } + + const legendMode = stringValue(draft.legendMode); + if (legendMode !== undefined) { + legend.displayMode = legendMode; + } + + const legendPlacement = stringValue(draft.legendPlacement); + if (legendPlacement !== undefined) { + legend.placement = legendPlacement; + } + + if (panel.type === 'piechart') { + const legendValues = stringArrayValue(draft.pieLegendValues); + if (legendValues !== undefined) { + legend.values = legendValues; + } + } + + options.legend = legend; +} + +function applyTooltipOptions(options: Record, draft: Readonly>): void { + const tooltipMode = stringValue(draft.tooltipMode); + const tooltipSort = stringValue(draft.tooltipSort); + if (tooltipMode === undefined && tooltipSort === undefined) { + return; + } + + const tooltip = isRecord(options.tooltip) ? { ...options.tooltip } : { mode: 'single', sort: 'none' }; + if (tooltipMode !== undefined) { + tooltip.mode = tooltipMode; + } + if (tooltipSort !== undefined) { + tooltip.sort = tooltipSort; + } + options.tooltip = tooltip; +} + +function applySharedGraphOptions( + panel: PanelModel, + options: Record, + draft: Readonly> +): void { + if (!supportsLegendAndTooltip(panel)) { + return; + } + + applyLegendOptions(panel, options, draft); + applyTooltipOptions(options, draft); +} + +export function applyFnPanelOptionsPreview(panel: PanelModel, update: FnPanelOptionsUpdate): void { + const draft = update.options; + const previousFieldConfig = JSON.stringify(panel.fieldConfig ?? { defaults: {}, overrides: [] }); + const previousOptions = JSON.stringify(panel.options ?? {}); + const options = isRecord(panel.options) ? { ...panel.options } : {}; + const { fieldConfig, defaults, custom } = panelFieldConfigParts(panel); + let frameOptionsChanged = false; + + const title = stringValue(draft.title); + if (title !== undefined && panel.title !== title) { + panel.title = title; + frameOptionsChanged = true; + } + + const description = stringValue(draft.description); + if (description !== undefined && panel.description !== description) { + panel.description = description; + frameOptionsChanged = true; + } + + const unit = stringValue(draft.unit); + if (unit !== undefined) { + defaults.unit = unit; + } + + const decimals = numberValue(draft.decimals); + if (decimals !== undefined) { + defaults.decimals = decimals; + } + + switch (panel.type) { + case 'stat': + case 'gauge': { + const fontSize = numberValue(draft.fontSize); + if (fontSize !== undefined) { + const text = isRecord(options.text) ? { ...options.text } : {}; + text.valueSize = fontSize; + options.text = text; + } + break; + } + case 'table': { + const showHeader = booleanValue(draft.tableShowHeader); + if (showHeader !== undefined) { + options.showHeader = showHeader; + } + + const cellHeight = stringValue(draft.tableCellHeight); + if (cellHeight !== undefined) { + options.cellHeight = cellHeight; + } + + const tableColumnFilter = booleanValue(draft.tableColumnFilter); + if (tableColumnFilter !== undefined) { + custom.filterable = tableColumnFilter; + } + + const tableSortBy = stringValue(draft.tableSortBy); + if (tableSortBy !== undefined) { + const displayName = tableSortBy.trim(); + options.sortBy = displayName ? [{ displayName, desc: booleanValue(draft.tableSortDesc) ?? false }] : []; + } + + const pagination = booleanValue(draft.tablePagination); + const footerVisible = booleanValue(draft.tableFooter); + if (pagination !== undefined || footerVisible !== undefined) { + const footer: Record = isRecord(options.footer) + ? { ...options.footer } + : { countRows: false, fields: '', reducer: ['sum'], show: false }; + if (pagination !== undefined) { + footer.enablePagination = pagination; + } + if (footerVisible !== undefined) { + footer.show = footerVisible; + } + options.footer = footer; + } + break; + } + case 'timeseries': { + const drawStyle = stringValue(draft.timeseriesDrawStyle); + if (drawStyle !== undefined) { + custom.drawStyle = drawStyle; + } + + const lineInterpolation = stringValue(draft.timeseriesLineInterpolation); + if (lineInterpolation !== undefined) { + custom.lineInterpolation = lineInterpolation; + } + + const lineWidth = numberValue(draft.timeseriesLineWidth); + if (lineWidth !== undefined) { + custom.lineWidth = lineWidth; + } + + const fillOpacity = numberValue(draft.timeseriesFillOpacity); + if (fillOpacity !== undefined) { + custom.fillOpacity = fillOpacity; + } + + const showPoints = stringValue(draft.timeseriesShowPoints); + if (showPoints !== undefined) { + custom.showPoints = showPoints; + } + + const pointSize = numberValue(draft.timeseriesPointSize); + if (pointSize !== undefined) { + custom.pointSize = pointSize; + } + + const stacking = stringValue(draft.timeseriesStacking); + if (stacking !== undefined) { + const existingStacking = isRecord(custom.stacking) ? custom.stacking : {}; + custom.stacking = { + ...existingStacking, + group: stringValue(existingStacking.group) ?? 'A', + mode: stacking, + }; + } + break; + } + case 'barchart': { + const orientation = stringValue(draft.orientation); + if (orientation !== undefined) { + options.orientation = orientation; + } + + const showValue = stringValue(draft.barShowValue); + if (showValue !== undefined) { + options.showValue = showValue; + } + + const stacking = stringValue(draft.barStacking); + if (stacking !== undefined) { + options.stacking = stacking; + } + + const groupWidth = numberValue(draft.barGroupWidth); + if (groupWidth !== undefined) { + options.groupWidth = groupWidth; + } + + const barWidth = numberValue(draft.barWidth); + if (barWidth !== undefined) { + options.barWidth = barWidth; + } + + const barRadius = numberValue(draft.barRadius); + if (barRadius !== undefined) { + options.barRadius = barRadius; + } + + const fillOpacity = numberValue(draft.barFillOpacity); + if (fillOpacity !== undefined) { + custom.fillOpacity = fillOpacity; + } + + const tickRotation = numberValue(draft.barTickLabelRotation); + if (tickRotation !== undefined) { + options.xTickLabelRotation = tickRotation; + } + + const tickMaxLength = numberValue(draft.barTickLabelMaxLength); + if (tickMaxLength !== undefined) { + options.xTickLabelMaxLength = tickMaxLength; + } + break; + } + case 'piechart': { + const pieType = stringValue(draft.pieType); + if (pieType !== undefined) { + options.pieType = pieType; + } + + const labels = stringArrayValue(draft.pieDisplayLabels); + if (labels !== undefined) { + options.displayLabels = labels; + } + break; + } + } + + applySharedGraphOptions(panel, options, draft); + + defaults.custom = custom; + fieldConfig.defaults = defaults; + + const fieldConfigChanged = JSON.stringify(fieldConfig) !== previousFieldConfig; + const optionsChanged = JSON.stringify(options) !== previousOptions; + + if (fieldConfigChanged) { + panel.updateFieldConfig(fieldConfig); + } + + if (optionsChanged) { + panel.updateOptions(options); + } + + if (frameOptionsChanged && !fieldConfigChanged && !optionsChanged) { + panel.render(); + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index fef25cc59b942..c24ceaf3e11f8 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -8,6 +8,7 @@ import { TextBoxVariableModel } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import appEvents from 'app/core/app_events'; +import { GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { GetVariables } from 'app/features/variables/state/selectors'; import { VariablesChanged } from 'app/features/variables/types'; @@ -17,7 +18,7 @@ import { DashboardMeta } from 'app/types'; import { DashboardModel } from '../state'; import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures'; -import { DashboardGrid, Props } from './DashboardGrid'; +import { Component, DashboardGrid, Props } from './DashboardGrid'; import { Props as LazyLoaderProps } from './LazyLoader'; jest.mock('@grafana/runtime', () => ({ @@ -55,6 +56,21 @@ function setup(props: Props) { ); } +function getRect(overrides: Partial): DOMRect { + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + toJSON: () => ({}), + top: 0, + width: 0, + x: 0, + y: 0, + ...overrides, + }; +} + function getTestDashboard( overrides?: Partial, metaOverrides?: Partial, @@ -115,6 +131,96 @@ describe('DashboardGrid', () => { expect(await screen.findByText('My gauge')).toBeInTheDocument(); }); + it('Should render only the selected panel in embedded view-panel mode', async () => { + const dashboard = getTestDashboard(); + const viewPanel = dashboard.getPanelById(2); + dashboard.initViewPanel(viewPanel); + + const props: Props = { + editPanel: null, + viewPanel, + isEditable: false, + isFnDashboard: true, + portalContainerID: 'grafana-portal', + dashboard, + }; + + act(() => { + setup(props); + }); + + expect(await screen.findByText('My table')).toBeInTheDocument(); + expect(screen.queryByText('My graph')).toBeNull(); + expect(screen.queryByText('My table 2')).toBeNull(); + expect(screen.queryByText('My gauge')).toBeNull(); + }); + + it('Should normalize selected panel layout in embedded view-panel mode without changing the dashboard model', () => { + const dashboard = getTestDashboard(); + const viewPanel = dashboard.getPanelById(2); + const originalGridPos = { x: 12, y: 10, w: 12, h: 10 }; + viewPanel.gridPos = { ...originalGridPos }; + dashboard.initViewPanel(viewPanel); + + const props: Props = { + editPanel: null, + viewPanel, + isEditable: false, + isFnDashboard: true, + portalContainerID: 'grafana-portal', + dashboard, + }; + + const grid = new Component(props); + const layout = grid.buildLayout(); + const panelLayout = layout[0]!; + + expect(layout).toHaveLength(1); + expect(panelLayout).toEqual( + expect.objectContaining({ + x: 0, + y: 0, + w: GRID_COLUMN_COUNT, + h: originalGridPos.h, + }) + ); + + grid.onLayoutChange([{ ...panelLayout, x: 8, y: 5, w: 8, h: 8 }]); + + expect(viewPanel.gridPos).toEqual(originalGridPos); + }); + + it('Should measure embedded view-panel height below dashboard controls', () => { + const dashboard = getTestDashboard(); + const viewPanel = dashboard.getPanelById(2); + dashboard.initViewPanel(viewPanel); + const portal = document.createElement('div'); + const gridWrapper = document.createElement('div'); + portal.id = 'grafana-portal'; + portal.appendChild(gridWrapper); + document.body.appendChild(portal); + + jest.spyOn(portal, 'getBoundingClientRect').mockReturnValue(getRect({ bottom: 680, height: 575, top: 105 })); + jest.spyOn(gridWrapper, 'getBoundingClientRect').mockReturnValue(getRect({ bottom: 541, height: 372, top: 169 })); + + const props: Props = { + editPanel: null, + viewPanel, + isEditable: false, + isFnDashboard: true, + portalContainerID: portal.id, + dashboard, + }; + const grid = new Component(props); + Object.defineProperty(grid, 'gridWrapperElement', { + value: gridWrapper, + }); + + expect(grid.getEmbeddedViewPanelHeight()).toBe(680 - 169 - GRID_CELL_VMARGIN); + + portal.remove(); + }); + it('Should allow filtering panels', async () => { const props: Props = { editPanel: null, diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 70c88a5d54182..df0b40f5175d0 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -22,13 +22,57 @@ import { DashboardPanel } from './DashboardPanel'; export interface Props { dashboard: DashboardModel; isEditable: boolean; + isLayoutEditable?: boolean; editPanel: PanelModel | null; viewPanel: PanelModel | null; hidePanelMenus?: boolean; isFnDashboard?: boolean; + onLayoutUpdate?: () => void; + portalContainerID?: string; } -export class Component extends PureComponent { +interface State { + /** Host-measured height for an embedded single-panel view. */ + viewPanelHeight?: number; +} + +type GridResizeHandle = NonNullable[number]; +type ResizeHandleRenderer = (resizeHandle: GridResizeHandle, ref: React.RefObject) => React.ReactNode; + +const customDashboardResizeHandles: GridResizeHandle[] = ['e', 's', 'se']; +const customDashboardResizeHandleBaseStyle: CSSProperties = { + display: 'block', + pointerEvents: 'auto', + position: 'absolute', + touchAction: 'none', + visibility: 'visible', + zIndex: 20, +}; + +const customDashboardResizeHandleStyles: Record = { + e: { ...customDashboardResizeHandleBaseStyle, cursor: 'ew-resize', height: '100%', right: -6, top: 0, width: 12 }, + n: { ...customDashboardResizeHandleBaseStyle, cursor: 'ns-resize', height: 12, left: 0, top: -6, width: '100%' }, + ne: { ...customDashboardResizeHandleBaseStyle, cursor: 'ne-resize', height: 28, right: -6, top: -6, width: 28 }, + nw: { ...customDashboardResizeHandleBaseStyle, cursor: 'nw-resize', height: 28, left: -6, top: -6, width: 28 }, + s: { ...customDashboardResizeHandleBaseStyle, bottom: -6, cursor: 'ns-resize', height: 12, left: 0, width: '100%' }, + se: { ...customDashboardResizeHandleBaseStyle, bottom: -6, cursor: 'se-resize', height: 28, right: -6, width: 28 }, + sw: { ...customDashboardResizeHandleBaseStyle, bottom: -6, cursor: 'sw-resize', height: 28, left: -6, width: 28 }, + w: { ...customDashboardResizeHandleBaseStyle, cursor: 'ew-resize', height: '100%', left: -6, top: 0, width: 12 }, +}; + +const renderCustomDashboardResizeHandle: ResizeHandleRenderer = (resizeHandle, ref) => ( +