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/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.ts b/public/app/core/reducers/fn-slice.ts index 0c885e2c770a0..1a7732d427f67 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -12,6 +12,12 @@ export interface FnState { 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; metadata: { teams: string[]; eventListener: ((event: { type: string; data: T }) => void) | null; @@ -25,7 +31,15 @@ export type SetFnStateAction = PayloadAction; export type FnStateProp = keyof FnState; @@ -33,6 +47,7 @@ export type FnPropsMappedFromState = Pick; export const fnStateProps: FnStateProp[] = [ 'controlsContainer', + 'enablePanelEdit', 'hiddenVariables', 'pageTitle', 'queryParams', @@ -54,6 +69,7 @@ export const INITIAL_FN_STATE: FnState = { pageTitle: '', queryParams: {}, hiddenVariables: [], + enablePanelEdit: false, metadata: { teams: [], eventListener: null, diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 70c88a5d54182..c55bcc4af906a 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -26,9 +26,15 @@ export interface Props { viewPanel: PanelModel | null; hidePanelMenus?: boolean; isFnDashboard?: boolean; + portalContainerID?: string; } -export class Component extends PureComponent { +interface State { + /** Host-measured height for an embedded single-panel view. */ + viewPanelHeight?: number; +} + +export class Component extends PureComponent { private panelMap: { [key: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; @@ -37,18 +43,69 @@ export class Component extends PureComponent { /** Used to keep track of mobile panel layout position */ private lastPanelBottom = 0; private isLayoutInitialized = false; + private portalResizeObserver?: ResizeObserver; constructor(props: Props) { super(props); + this.state = { viewPanelHeight: undefined }; } componentDidMount() { const { dashboard } = this.props; this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate)); + this.observePortalContainer(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.portalContainerID !== this.props.portalContainerID || prevProps.viewPanel !== this.props.viewPanel) { + this.observePortalContainer(); + } } componentWillUnmount() { this.eventSubs.unsubscribe(); + this.portalResizeObserver?.disconnect(); + this.portalResizeObserver = undefined; + } + + /** + * Embedded (MFE) single-panel mode only. + * + * The host sizes the portal container, but on first paint it can still measure + * 0 (the portal div mounts before the host flex layout resolves). A one-shot + * read therefore falls back to `windowHeight * 0.85`, which overflows the host + * box and crops the panel. Observing the container keeps the panel height in + * sync with whatever the host actually allocates, including window resizes. + */ + observePortalContainer() { + this.portalResizeObserver?.disconnect(); + this.portalResizeObserver = undefined; + + const { isFnDashboard, viewPanel, portalContainerID } = this.props; + if (!isFnDashboard || !viewPanel || !portalContainerID) { + if (this.state.viewPanelHeight !== undefined) { + this.setState({ viewPanelHeight: undefined }); + } + return; + } + + const portalContainer = document.getElementById(portalContainerID); + if (!portalContainer) { + return; + } + + this.portalResizeObserver = new ResizeObserver(() => { + const height = portalContainer.clientHeight; + if (height > 0 && height !== this.state.viewPanelHeight) { + this.setState({ viewPanelHeight: height }); + } + }); + this.portalResizeObserver.observe(portalContainer); + + const height = portalContainer.clientHeight; + if (height > 0 && height !== this.state.viewPanelHeight) { + this.setState({ viewPanelHeight: height }); + } } buildLayout() { @@ -138,8 +195,25 @@ export class Component extends PureComponent { return { top, bottom: this.lastPanelBottom }; } + getEmbeddedViewPanelHeight(): number | undefined { + if (!this.props.isFnDashboard || !this.props.viewPanel || !this.props.portalContainerID) { + return undefined; + } + + // Prefer the observed height; fall back to a direct read for the first paint + // before the ResizeObserver has delivered its initial entry. + if (this.state.viewPanelHeight !== undefined) { + return this.state.viewPanelHeight; + } + + const portalContainer = document.getElementById(this.props.portalContainerID); + const height = portalContainer?.clientHeight ?? 0; + return height > 0 ? height : undefined; + } + renderPanels(gridWidth: number, isDashboardDraggable: boolean) { const panelElements = []; + const viewPanelHeight = this.getEmbeddedViewPanelHeight(); // Reset last panel bottom this.lastPanelBottom = 0; @@ -164,6 +238,7 @@ export class Component extends PureComponent { gridWidth={gridWidth} windowHeight={this.windowHeight} windowWidth={this.windowWidth} + viewPanelHeight={viewPanelHeight} isViewing={panel.isViewing} > {(width: number, height: number) => { @@ -279,6 +354,7 @@ interface GrafanaGridItemProps extends React.HTMLAttributes { isViewing: boolean; windowHeight: number; windowWidth: number; + viewPanelHeight?: number; children: any; } @@ -290,13 +366,15 @@ const GrafanaGridItem = React.forwardRef(( let width = 100; let height = 100; - const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, ...divProps } = props; + const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, viewPanelHeight, ...divProps } = props; const style: CSSProperties = props.style ?? {}; if (isViewing) { - // In fullscreen view mode a single panel take up full width & 85% height + // In fullscreen view mode a single panel take up full width & 85% height. + // Embedded FN dashboards have a host-owned viewport, so use that measured + // height instead of the browser window to avoid cropping inside the host. width = gridWidth!; - height = windowHeight * 0.85; + height = viewPanelHeight ?? windowHeight * 0.85; style.height = height; style.width = '100%'; } else if (windowWidth < theme.breakpoints.values.md) { @@ -339,6 +417,7 @@ GrafanaGridItem.displayName = 'GridItemWithDimensions'; function mapStateToProps() { return (state: StoreState) => ({ isFnDashboard: state.fnGlobalState.FNDashboard, + portalContainerID: state.fnGlobalState.portalContainerID, }); } diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx index 044e5e54f4c4d..e5f3418d30fb7 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx @@ -5,7 +5,7 @@ import { LoadingState, TimeRange } from '@grafana/data'; import { AngularNotice, PanelHeaderTitleItems } from './PanelHeaderTitleItems'; -function renderComponent(angularNoticeOverride?: Partial) { +function renderComponent(angularNoticeOverride?: Partial, onEditPanel?: () => void) { render( ) { timeRange: {} as TimeRange, }} panelId={1} + onEditPanel={onEditPanel} angularNotice={{ ...{ show: true, @@ -73,3 +74,23 @@ describe('PanelHeaderTitleItems angular deprecation', () => { }); }); }); + +describe('PanelHeaderTitleItems panel edit affordance', () => { + const editSelector = 'fn-edit-panel'; + + it('does not render the edit item when the host has not opted in', () => { + renderComponent(); + expect(screen.queryByTestId(editSelector)).not.toBeInTheDocument(); + }); + + it('renders the edit item and reports clicks to the host', async () => { + const onEditPanel = jest.fn(); + renderComponent(undefined, onEditPanel); + + const editItem = screen.getByTestId(editSelector); + expect(editItem).toBeInTheDocument(); + + await userEvent.click(editItem); + expect(onEditPanel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx index 2399730beca44..d684e9d3525eb 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx @@ -17,15 +17,38 @@ export interface Props { alertState?: string; data: PanelData; panelId: number; + /** + * Set only when the embedding host has opted into panel editing. Rendering the + * affordance is driven entirely by this callback's presence. + */ + onEditPanel?: () => void; onShowPanelLinks?: () => Array>; panelLinks?: DataLink[]; angularNotice?: AngularNotice; } export function PanelHeaderTitleItems(props: Props) { - const { alertState, data, panelId, onShowPanelLinks, panelLinks, angularNotice } = props; + const { alertState, data, panelId, onEditPanel, onShowPanelLinks, panelLinks, angularNotice } = props; const styles = useStyles2(getStyles); + const editItem = ( + + { + // The header doubles as the drag handle, so keep the click local. + e.preventDefault(); + e.stopPropagation(); + onEditPanel?.(); + }} + > + + + + ); + // panel health const alertStateItem = ( @@ -72,6 +95,7 @@ export function PanelHeaderTitleItems(props: Props) { {timeshift} {alertState && alertStateItem} {angularNotice?.show && angularNoticeTooltip} + {onEditPanel && editItem} ); } @@ -118,5 +142,12 @@ const getStyles = (theme: GrafanaTheme2) => { angularNotice: css({ color: theme.colors.warning.text, }), + editPanel: css({ + color: theme.colors.text.secondary, + cursor: 'pointer', + '&:hover': { + color: theme.colors.text.primary, + }, + }), }; }; diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index bc9cd47326f7d..efa3c660d195c 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -644,6 +644,12 @@ export class PanelStateWrapperDisConnected extends PureComponent { function mapStateToProps() { return (state: StoreState) => ({ isFnDashboard: state.fnGlobalState.FNDashboard, + /** + * Read from the per-dashboard store so the edit affordance follows whichever + * dashboard this panel belongs to. + */ + enablePanelEdit: state.fnGlobalState.enablePanelEdit, + panelEditListener: state.fnGlobalState.metadata?.eventListener ?? undefined, }); } diff --git a/public/app/features/dashboard/utils/getPanelChromeProps.tsx b/public/app/features/dashboard/utils/getPanelChromeProps.tsx index 52a0e96ec79f0..d0aaef4098907 100644 --- a/public/app/features/dashboard/utils/getPanelChromeProps.tsx +++ b/public/app/features/dashboard/utils/getPanelChromeProps.tsx @@ -18,6 +18,10 @@ interface CommonProps { plugin: PanelPlugin; isViewing: boolean; isEditing: boolean; + /** Host opt-in for the per-panel edit affordance. */ + enablePanelEdit?: boolean; + /** Channel the host listens on for `panelEditClick`. */ + panelEditListener?: (event: { type: string; data: T }) => void; isInView: boolean; isDraggable?: boolean; width: number; @@ -84,11 +88,27 @@ export function getPanelChromeProps(props: CommonProps) { const showAngularNotice = (config.featureToggles.angularDeprecationUI ?? false) && (isAngularDatasource || isAngularPanel); + /** + * Panel editing is host-driven: the host opts in with `enablePanelEdit` and + * receives the clicked panel through the same `eventListener` channel used by + * the stat/table/timeseries click events. Grafana renders only the trigger. + */ + const panelEditListener = props.enablePanelEdit ? props.panelEditListener : undefined; + + const onEditPanel = panelEditListener + ? () => + panelEditListener({ + type: 'panelEditClick', + data: { panelId: props.panel.id, title: props.panel.title ?? '' }, + }) + : undefined; + const showTitleItems = (props.panel.links && props.panel.links.length > 0 && onShowPanelLinks) || (props.data.series.length > 0 && props.data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) || (props.data.request && props.data.request.timeInfo) || showAngularNotice || + Boolean(onEditPanel) || alertState; const titleItems = showTitleItems && ( @@ -96,6 +116,7 @@ export function getPanelChromeProps(props: CommonProps) { alertState={alertState} data={props.data} panelId={props.panel.id} + onEditPanel={onEditPanel} panelLinks={props.panel.links} angularNotice={{ show: showAngularNotice, diff --git a/public/microfrontends/fn_dashboard/index.html b/public/microfrontends/fn_dashboard/index.html index 23eb7834dec4d..c0ecf10512264 100644 --- a/public/microfrontends/fn_dashboard/index.html +++ b/public/microfrontends/fn_dashboard/index.html @@ -1,6 +1,25 @@ -CodeRabbit Micro-frontend
\ No newline at end of file + }; + + + + + + + + + diff --git a/yarn.lock b/yarn.lock index ef6a85c6f40eb..f69c444a17775 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4068,6 +4068,7 @@ __metadata: "@grafana/schema": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@hello-pangea/dnd": "npm:16.6.0" + "@heroicons/react": "npm:2.2.0" "@leeoniya/ufuzzy": "npm:1.0.14" "@monaco-editor/react": "npm:4.6.0" "@popperjs/core": "npm:2.11.8" @@ -4228,6 +4229,15 @@ __metadata: languageName: node linkType: hard +"@heroicons/react@npm:2.2.0": + version: 2.2.0 + resolution: "@heroicons/react@npm:2.2.0" + peerDependencies: + react: ">= 16 || ^19.0.0-rc" + checksum: 10/5bf8a3faa16f1566165bfaec2448ce3c2bd3e4f49e446ccf233f6aa63626155585eaf7e89f01fb4e8e92dfca3d7113b8c517c3b3c04036b9243c818595db3908 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.13, @humanwhocodes/config-array@npm:^0.11.14": version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14"