wip per panel group by

This commit is contained in:
Victor Marin
2025-11-03 15:28:37 +02:00
parent 6d9e28a59f
commit da26ce43f5
7 changed files with 250 additions and 0 deletions
@@ -408,6 +408,10 @@ export interface FeatureToggles {
*/
unlimitedLayoutsNesting?: boolean;
/**
* Enabled a group by action per panel
*/
panelGroupBy?: boolean;
/**
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
*/
panelFilterVariable?: boolean;
+7
View File
@@ -678,6 +678,13 @@ var (
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "panelGroupBy",
Description: "Enabled a group by action per panel",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "panelFilterVariable",
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
+1
View File
@@ -90,6 +90,7 @@ dashboardScene,GA,@grafana/dashboards-squad,false,false,true
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,true
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
90 dashboardNewLayouts experimental @grafana/dashboards-squad false false true
91 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
92 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
93 panelGroupBy experimental @grafana/dashboards-squad false false true
94 panelFilterVariable experimental @grafana/dashboards-squad false false true
95 pdfTables preview @grafana/grafana-operator-experience-squad false false false
96 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
+4
View File
@@ -371,6 +371,10 @@ const (
// Enables unlimited dashboard panel grouping
FlagUnlimitedLayoutsNesting = "unlimitedLayoutsNesting"
// FlagPanelGroupBy
// Enabled a group by action per panel
FlagPanelGroupBy = "panelGroupBy"
// FlagPanelFilterVariable
// Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
FlagPanelFilterVariable = "panelFilterVariable"
+13
View File
@@ -2884,6 +2884,19 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "panelGroupBy",
"resourceVersion": "1762169261546",
"creationTimestamp": "2025-11-03T11:27:41Z"
},
"spec": {
"description": "Enabled a group by action per panel",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad",
"frontend": true
}
},
{
"metadata": {
"name": "panelMonitoring",
@@ -0,0 +1,219 @@
import { css, cx } from '@emotion/css';
import { useState, useCallback } from 'react';
import { lastValueFrom } from 'rxjs';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import {
GroupByVariable,
SceneComponentProps,
sceneGraph,
SceneObjectBase,
VariableValueOption,
VizPanel,
} from '@grafana/scenes';
import { Button, Dropdown, Icon, Input, useStyles2, Checkbox } from '@grafana/ui';
export class PanelGroupByAction extends SceneObjectBase {
static Component = PanelGroupByActionRenderer;
constructor() {
super({});
this.addActivationHandler(this.onActivate);
}
private onActivate = () => {
const panel = this.parent;
if (!panel || !(panel instanceof VizPanel)) {
throw new Error('PanelGroupByAction can be used only for VizPanel');
}
};
public getGroupByVariable() {
return sceneGraph.getVariables(this).state.variables.find((variable) => variable instanceof GroupByVariable);
}
}
function PanelGroupByActionRenderer({ model }: SceneComponentProps<PanelGroupByAction>) {
const groupByVariable = model.getGroupByVariable();
const groupByState = groupByVariable?.useState();
const styles = useStyles2(getStyles);
// Track temporary selections (not applied yet)
const [tempSelectedItems, setTempSelectedItems] = useState<VariableValueOption[]>([]);
// Track applied selections
const [appliedSelectedItems, setAppliedSelectedItems] = useState<VariableValueOption[]>([]);
// Track search input
const [searchValue, setSearchValue] = useState('');
// Track loading state
const [isLoading, setIsLoading] = useState(false);
// Check if an option is selected in temp state
const isOptionSelected = useCallback(
(item: VariableValueOption) => tempSelectedItems.some((opt) => opt.value === item.value),
[tempSelectedItems]
);
// Toggle selection in temp state
const handleItemClick = useCallback(
(item: VariableValueOption) => {
if (isOptionSelected(item)) {
setTempSelectedItems(tempSelectedItems.filter((opt) => opt.value !== item.value));
} else {
setTempSelectedItems([...tempSelectedItems, item]);
}
},
[isOptionSelected, tempSelectedItems]
);
// Apply selections and close dropdown
const handleApply = () => {
setAppliedSelectedItems(tempSelectedItems);
};
// When dropdown opens, fetch options if needed and sync temp state with applied state
const handleOpenChange = async (open: boolean) => {
if (open) {
// If groupBy has no options, fetch them
if (groupByVariable && (!groupByState?.options || groupByState.options.length === 0)) {
setIsLoading(true);
await lastValueFrom(groupByVariable.validateAndUpdate());
}
setTempSelectedItems(appliedSelectedItems);
setSearchValue('');
}
};
if (!groupByState) {
return null;
}
const menu = (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className={styles.menuContainer} onClick={(e) => e.stopPropagation()}>
<div className={styles.searchContainer}>
<Input
prefix={<Icon name="search" />}
placeholder={t('panel-group-by.search-placeholder', 'Search...')}
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
/>
</div>
<div className={styles.listContainer}>
{isLoading ? (
<div className={styles.emptyMessage}>
<Trans i18nKey="panel-group-by.loading">Loading options...</Trans>
</div>
) : groupByState.options.length === 0 ? (
<div className={styles.emptyMessage}>
<Trans i18nKey="panel-group-by.no-options">No options found</Trans>
</div>
) : (
groupByState.options.map((option) => {
const isSelected = isOptionSelected(option);
return (
<div
key={option.value as string}
className={cx(styles.option)}
onClick={(e) => {
e.stopPropagation();
handleItemClick(option);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleItemClick(option);
}
}}
role="button"
tabIndex={0}
aria-pressed={isSelected}
>
<Checkbox value={isSelected} onChange={() => handleItemClick(option)} />
<span className={styles.optionLabel}>{option.label}</span>
</div>
);
})
)}
</div>
<div className={styles.applyContainer}>
<Button
onClick={(e) => {
e.stopPropagation();
handleApply();
}}
>
<Trans i18nKey="panel-group-by.apply">Apply</Trans>
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
}}
variant="secondary"
fill="outline"
>
<Trans i18nKey="panel-group-by.cancel">Cancel</Trans>
</Button>
</div>
</div>
);
return (
<Dropdown overlay={menu} onVisibleChange={handleOpenChange} placement="bottom-start">
<Button variant="secondary" size="sm">
<Trans i18nKey="panel-group-by.button">Group by</Trans>
<Icon name="angle-down" />
</Button>
</Dropdown>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
menuContainer: css({
display: 'flex',
flexDirection: 'column',
width: '300px',
maxHeight: '400px',
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
}),
searchContainer: css({
padding: theme.spacing(1),
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
listContainer: css({
flex: 1,
overflow: 'auto',
minHeight: '200px',
maxHeight: '300px',
padding: theme.spacing(0.5),
}),
option: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1),
cursor: 'pointer',
borderRadius: theme.shape.radius.default,
'&:hover': {
background: theme.colors.background.secondary,
},
}),
optionLabel: css({
flex: 1,
}),
emptyMessage: css({
padding: theme.spacing(2),
textAlign: 'center',
color: theme.colors.text.secondary,
}),
applyContainer: css({
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(1),
borderTop: `1px solid ${theme.colors.border.weak}`,
}),
});
@@ -42,6 +42,7 @@ import { registerDashboardMacro } from '../scene/DashboardMacro';
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { PanelGroupByAction } from '../scene/PanelGroupByAction';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
@@ -437,6 +438,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
hoverHeaderOffset: 0,
$data: createPanelDataProvider(panel),
titleItems,
headerActions: config.featureToggles.panelGroupBy ? [new PanelGroupByAction()] : [],
$behaviors: [],
extendPanelContext: setDashboardPanelContext,
_UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel),