Dashboards: Add support for full screen panel view and embedded (solo panel) route to repeated panels and new layouts (via new SoloPanelContex) (#107375)

* SoloPanelContext: First steps

* working in default grid

* Fixes

* Update

* SoloPanelPage

* Fixing search layout

* Fixes

* Fixes

* Update

* remove comments

* Solving panel not found

* Fix

* fix lint

* fix lint
This commit is contained in:
Torkel Ödegaard 2025-08-25 12:23:11 +02:00 committed by GitHub
parent 7434a9d725
commit da5209be1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 298 additions and 484 deletions

View File

@ -70,9 +70,8 @@ export function PublicDashboardScenePage({ route }: Props) {
function PublicDashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const [isActive, setIsActive] = useState(false);
const { controls, title } = model.useState();
const { controls, title, body } = model.useState();
const { timePicker, refreshPicker, hideTimeControls } = controls!.useState();
const bodyToRender = model.getBodyToRender();
const styles = useStyles2(getStyles);
const conf = useGetPublicDashboardConfig();
@ -108,7 +107,7 @@ function PublicDashboardSceneRenderer({ model }: SceneComponentProps<DashboardSc
)}
</div>
<div className={styles.body}>
<bodyToRender.Component model={bodyToRender} />
<body.Component model={body} />
</div>
<PublicDashboardFooter />
</Page>

View File

@ -65,7 +65,6 @@ import { isRepeatCloneOrChildOf } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import { getDashboardUrl } from '../utils/getDashboardUrl';
import { getViewPanelUrl } from '../utils/urlBuilders';
import {
getClosestVizPanel,
getDashboardSceneFor,
@ -81,7 +80,6 @@ import { DashboardLayoutOrchestrator } from './DashboardLayoutOrchestrator';
import { DashboardSceneRenderer } from './DashboardSceneRenderer';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts';
import { AutoGridItem } from './layout-auto-grid/AutoGridItem';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
@ -129,8 +127,10 @@ export interface DashboardSceneState extends SceneObjectState {
meta: Omit<DashboardMeta, 'isNew'>;
/** Version of the dashboard */
version?: number;
/** Panel to view in fullscreen */
viewPanelScene?: ViewPanelScene;
/** Panel to inspect */
inspectPanelKey?: string;
/** Panel key to view in fullscreen */
viewPanel?: string;
/** Edit view */
editview?: DashboardEditView;
/** Edit panel */
@ -427,7 +427,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
}
public getPageNav(location: H.Location, navIndex: NavIndex) {
const { meta, viewPanelScene, editPanel, title, uid } = this.state;
const { meta, viewPanel, editPanel, title, uid } = this.state;
const isNew = !Boolean(uid);
let pageNav: NavModelItem = {
@ -456,11 +456,14 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
}
}
if (viewPanelScene) {
if (viewPanel) {
pageNav = {
text: t('dashboard-scene.dashboard-scene.text.view-panel', 'View panel'),
parentItem: pageNav,
url: getViewPanelUrl(viewPanelScene.state.panelRef.resolve()),
url: locationUtil.getUrlForPartial(locationService.getLocation(), {
viewPanel: viewPanel,
editPanel: undefined,
}),
};
}
@ -474,13 +477,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
return pageNav;
}
/**
* Returns the body (layout) or the full view panel
*/
public getBodyToRender(): SceneObject {
return this.state.viewPanelScene ?? this.state.body;
}
public getInitialState(): DashboardSceneState | undefined {
return this._initialState;
}

View File

@ -12,14 +12,16 @@ import { DashboardEditPaneSplitter } from '../edit-pane/DashboardEditPaneSplitte
import { DashboardScene } from './DashboardScene';
import { PanelSearchLayout } from './PanelSearchLayout';
import { SoloPanelContextProvider, useDefineSoloPanelContext } from './SoloPanelContext';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const {
controls,
overlay,
editview,
body,
editPanel,
viewPanelScene,
viewPanel,
panelSearch,
panelsPerRow,
isEditing,
@ -30,23 +32,23 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const scopesContext = useContext(ScopesContext);
const navIndex = useSelector((state) => state.navIndex);
const pageNav = model.getPageNav(location, navIndex);
const bodyToRender = model.getBodyToRender();
const navModel = getNavModel(navIndex, `dashboards/${type === 'snapshot' ? 'snapshots' : 'browse'}`);
const isSettingsOpen = editview !== undefined;
const soloPanelContext = useDefineSoloPanelContext(viewPanel);
// Remember scroll pos when going into view panel, edit panel or settings
useMemo(() => {
if (viewPanelScene || isSettingsOpen || editPanel) {
if (viewPanel || isSettingsOpen || editPanel) {
model.rememberScrollPos();
}
}, [isSettingsOpen, editPanel, viewPanelScene, model]);
}, [isSettingsOpen, editPanel, viewPanel, model]);
// Restore scroll pos when coming back
useEffect(() => {
if (!viewPanelScene && !isSettingsOpen && !editPanel) {
if (!viewPanel && !isSettingsOpen && !editPanel) {
model.restoreScrollPos();
}
}, [isSettingsOpen, editPanel, viewPanelScene, model]);
}, [isSettingsOpen, editPanel, viewPanel, model]);
useEffect(() => {
if (scopesContext && isEditing) {
@ -70,11 +72,19 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
}
function renderBody() {
if (!viewPanelScene && (panelSearch || panelsPerRow)) {
if (!viewPanel && (panelSearch || panelsPerRow)) {
return <PanelSearchLayout panelSearch={panelSearch} panelsPerRow={panelsPerRow} dashboard={model} />;
}
return <bodyToRender.Component model={bodyToRender} />;
if (soloPanelContext) {
return (
<SoloPanelContextProvider value={soloPanelContext} singleMatch={true} dashboard={model}>
<body.Component model={body} />
</SoloPanelContextProvider>
);
}
return <body.Component model={body} />;
}
return (

View File

@ -1,21 +1,11 @@
import { AppEvents } from '@grafana/data';
import { LocalValueVariable, SceneQueryRunner, SceneVariableSet, VizPanel } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { KioskMode } from 'app/types/dashboard';
import { DashboardScene } from './DashboardScene';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
describe('DashboardSceneUrlSync', () => {
describe('Given a standard scene', () => {
it('Should set viewPanelKey when url has viewPanel', () => {
const scene = buildTestScene();
scene.urlSync?.updateFromUrl({ viewPanel: '2' });
expect(scene.state.viewPanelScene!.getUrlKey()).toBe('panel-2');
});
it('Should set UNSAFE_fitPanels when url has autofitpanels', () => {
const scene = buildTestScene();
scene.urlSync?.updateFromUrl({ autofitpanels: '' });
@ -58,54 +48,11 @@ describe('DashboardSceneUrlSync', () => {
const scene = buildTestScene();
scene.setState({ isEditing: false });
scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1' });
expect(scene.state.viewPanelScene).toBeDefined();
expect(scene.state.viewPanel).toBeDefined();
scene.urlSync?.updateFromUrl({ editPanel: 'panel-1' });
expect(scene.state.editPanel).toBeDefined();
});
});
describe('Given a viewPanelKey with clone that is not found', () => {
const scene = buildTestScene();
let errorNotice = 0;
appEvents.on(AppEvents.alertError, (evt) => errorNotice++);
scene.urlSync?.updateFromUrl({ viewPanel: 'A$panel-1' });
expect(scene.state.viewPanelScene).toBeUndefined();
// Verify no error notice was shown
expect(errorNotice).toBe(0);
// fake adding clone panel
const layout = scene.state.body as DefaultGridLayoutManager;
layout.state.grid.setState({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
body: new VizPanel({
$variables: new SceneVariableSet({
variables: [
new LocalValueVariable({
name: 'server',
value: 'A',
text: 'A',
}),
],
}),
title: 'Clone Panel A',
key: 'panel-1',
pluginId: 'table',
}),
}),
],
});
// Verify it subscribes to DashboardRepeatsProcessedEvent
scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene }));
expect(scene.state.viewPanelScene?.getUrlKey()).toBe('A$panel-1');
});
});
function buildTestScene() {

View File

@ -1,9 +1,5 @@
import { Unsubscribable } from 'rxjs';
import { AppEvents } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
import { KioskMode } from 'app/types/dashboard';
@ -11,18 +7,13 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { createDashboardEditViewFor } from '../settings/utils';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId';
import { findEditPanel, getLibraryPanelBehavior } from '../utils/utils';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
import { ViewPanelScene } from './ViewPanelScene';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
private _viewEventSub?: Unsubscribable;
constructor(private _scene: DashboardScene) {}
getKeys(): string[] {
@ -34,7 +25,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
return {
autofitpanels: this.getAutoFitPanels(),
viewPanel: state.viewPanelScene?.getUrlKey(),
viewPanel: state.viewPanel,
editview: state.editview?.getUrlKey(),
editPanel: state.editPanel?.getUrlKey() || undefined,
kiosk: state.kioskMode === KioskMode.Full ? '' : undefined,
@ -52,7 +43,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
updateFromUrl(values: SceneObjectUrlValues): void {
const { viewPanelScene, isEditing, editPanel, shareView } = this._scene.state;
const { viewPanel, isEditing, editPanel, shareView } = this._scene.state;
const update: Partial<DashboardSceneState> = {};
if (typeof values.editview === 'string' && this._scene.canEditDashboard()) {
@ -74,25 +65,9 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
// Handle view panel state
if (typeof values.viewPanel === 'string') {
const panel = findVizPanelByPathId(this._scene, values.viewPanel);
if (!panel) {
// If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet
// Here we check if the key contains the clone key so we force the repeat processing
// It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key
if (containsPathIdSeparator(values.viewPanel)) {
this._handleViewRepeatClone(values.viewPanel);
return;
}
appEvents.emit(AppEvents.alertError, ['Panel not found']);
locationService.partial({ viewPanel: null });
return;
}
update.viewPanelScene = new ViewPanelScene({ panelRef: panel.getRef() });
} else if (viewPanelScene && values.viewPanel === null) {
update.viewPanelScene = undefined;
update.viewPanel = values.viewPanel;
} else if (viewPanel && values.viewPanel === null) {
update.viewPanel = undefined;
}
// Handle edit panel state
@ -105,8 +80,8 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
// We cannot simultaneously be in edit and view panel state.
if (this._scene.state.viewPanelScene) {
this._scene.setState({ viewPanelScene: undefined });
if (this._scene.state.viewPanel) {
update.viewPanel = undefined;
}
// If we are not in editing (for example after full page reload)
@ -159,21 +134,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
}
}
private _handleViewRepeatClone(viewPanel: string) {
if (!this._viewEventSub) {
this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByPathId(this._scene, viewPanel);
if (panel) {
this._viewEventSub?.unsubscribe();
this._scene.setState({ viewPanelScene: new ViewPanelScene({ panelRef: panel.getRef() }) });
this._viewEventSub = undefined;
}
});
this._scene.state.body.activateRepeaters?.();
}
}
/**
* Temporary solution, with some refactoring of PanelEditor we can remove this
*/

View File

@ -66,7 +66,7 @@ NavToolbarActions.displayName = 'NavToolbarActions';
* This part is split into a separate component to help test this
*/
export function ToolbarActions({ dashboard }: Props) {
const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState();
const { isEditing, viewPanel, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false);
@ -75,7 +75,7 @@ export function ToolbarActions({ dashboard }: Props) {
const toolbarActions: ToolbarAction[] = [];
const styles = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene);
const isViewingPanel = Boolean(viewPanel);
const isEditedPanelDirty = usePanelEditDirty(editPanel);
const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve());

View File

@ -2,6 +2,7 @@ import {
getTimeZone,
InterpolateFunction,
LinkModel,
locationUtil,
PanelMenuItem,
PanelPlugin,
PluginExtensionLink,
@ -37,7 +38,7 @@ import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { DashboardInteractions } from '../utils/interactions';
import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getEditPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
@ -90,7 +91,10 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
text: t('panel.header-menu.view', `View`),
iconClassName: 'eye',
shortcut: 'v',
href: getViewPanelUrl(panel),
href: locationUtil.getUrlForPartial(locationService.getLocation(), {
viewPanel: panel.getPathId(),
editPanel: undefined,
}),
});
}

View File

@ -1,18 +1,13 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneGridRow, VizPanel, sceneGraph } from '@grafana/scenes';
import { VizPanel, sceneGraph } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { forceActivateFullSceneObjectTree } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent';
import { SoloPanelContextProvider } from './SoloPanelContext';
export interface Props {
dashboard: DashboardScene;
@ -24,60 +19,21 @@ const panelsPerRowCSSVar = '--panels-per-row';
export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }: Props) {
const { body } = dashboard.state;
const filteredPanels: VizPanel[] = [];
const styles = useStyles2(getStyles);
const [_, setRepeatsUpdated] = useState('');
const soloPanelContext = useMemo(() => new SoloPanelContextValueWithSearchStringFilter(panelSearch), [panelSearch]);
const bodyGrid = body instanceof DefaultGridLayoutManager ? body.state.grid : null;
if (!bodyGrid) {
return <Trans i18nKey="panel-search.unsupported-layout">Unsupported layout</Trans>;
}
for (const gridItem of bodyGrid.state.children) {
if (gridItem instanceof DashboardGridItem) {
filterPanels(gridItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated);
} else if (gridItem instanceof SceneGridRow) {
for (const rowItem of gridItem.state.children) {
if (rowItem instanceof DashboardGridItem) {
filterPanels(rowItem, dashboard, panelSearch, filteredPanels, setRepeatsUpdated);
}
}
}
}
if (filteredPanels.length > 0) {
return (
<div
className={classNames(styles.grid, { [styles.perRow]: panelsPerRow !== undefined })}
style={{ [panelsPerRowCSSVar]: panelsPerRow } as Record<string, number>}
>
{filteredPanels.map((panel) => (
<PanelSearchHit key={panel.state.key} panel={panel} />
))}
<SoloPanelContextProvider value={soloPanelContext} singleMatch={false} dashboard={dashboard}>
<body.Component model={body} />
</SoloPanelContextProvider>
</div>
);
}
return (
<p className={styles.noHits}>
<Trans i18nKey="panel-search.no-matches">No matches found</Trans>
</p>
);
}
function PanelSearchHit({ panel }: { panel: VizPanel }) {
useEffect(() => {
const deactivate = forceActivateFullSceneObjectTree(panel);
return () => {
deactivate?.();
};
}, [panel]);
return <panel.Component model={panel} />;
}
function getStyles(theme: GrafanaTheme2) {
return {
grid: css({
@ -96,37 +52,20 @@ function getStyles(theme: GrafanaTheme2) {
};
}
function filterPanels(
gridItem: DashboardGridItem,
dashboard: DashboardScene,
searchString: string,
filteredPanels: VizPanel[],
setRepeatsUpdated: (updated: string) => void
) {
const interpolatedSearchString = sceneGraph.interpolate(dashboard, searchString).toLowerCase();
export class SoloPanelContextValueWithSearchStringFilter {
public matchFound = false;
// activate inactive repeat panel if one of its children will be matched
if (gridItem.state.variableName && !gridItem.isActive) {
const panel = gridItem.state.body;
public constructor(private searchQuery: string) {}
public matches(panel: VizPanel): boolean {
const interpolatedSearchString = sceneGraph.interpolate(panel, this.searchQuery).toLowerCase();
const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase();
if (interpolatedTitle.includes(interpolatedSearchString)) {
gridItem.subscribeToEvent(DashboardRepeatsProcessedEvent, (event) => {
const source = event.payload.source;
if (source instanceof DashboardGridItem) {
setRepeatsUpdated(event.payload.source.state.key ?? '');
}
});
gridItem.activate();
}
const match = interpolatedTitle.includes(interpolatedSearchString);
if (match) {
this.matchFound = true;
}
const panels = gridItem.state.repeatedPanels ?? [gridItem.state.body];
for (const panel of panels) {
const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase();
if (interpolatedTitle.includes(interpolatedSearchString)) {
filteredPanels.push(panel);
return match;
}
}
return filteredPanels;
}

View File

@ -0,0 +1,141 @@
import React, { useContext, useEffect, useState } from 'react';
import { Trans } from '@grafana/i18n';
import { VizPanel } from '@grafana/scenes';
import { Box, Spinner } from '@grafana/ui';
import { DashboardScene } from './DashboardScene';
export interface SoloPanelContextValue {
matches: (VizPanel: VizPanel) => boolean;
matchFound: boolean;
}
export class SoloPanelContextWithPathIdFilter implements SoloPanelContextValue {
public matchFound = false;
public constructor(public keyPath: string) {}
public matches(panel: VizPanel): boolean {
// Check if keyPath is just an old legacy panel id
if (/^\d+$/.test(this.keyPath)) {
if (`panel-${this.keyPath}` === panel.state.key!) {
this.matchFound = true;
return true;
}
return false;
}
if (this.keyPath === panel.getPathId()) {
this.matchFound = true;
return true;
}
return false;
}
}
export const SoloPanelContext = React.createContext<SoloPanelContextValue | null>(null);
export function useDefineSoloPanelContext(keyPath?: string): SoloPanelContextValue | null {
return React.useMemo(() => {
if (!keyPath) {
return null;
}
return new SoloPanelContextWithPathIdFilter(keyPath);
}, [keyPath]);
}
export function useSoloPanelContext() {
return useContext(SoloPanelContext);
}
export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) {
const matches: React.ReactNode[] = [];
for (const panel of panels) {
if (soloPanelContext.matches(panel)) {
matches.push(<panel.Component model={panel} key={panel.state.key} />);
}
}
return <>{matches}</>;
}
export function SoloPanelContextProvider({
children,
value,
singleMatch,
dashboard,
}: {
children: React.ReactNode;
value: SoloPanelContextValue;
singleMatch: boolean;
dashboard: DashboardScene;
}) {
return (
<SoloPanelContext.Provider value={value}>
{children}
<SoloPanelNotFound singleMatch={singleMatch} dashboard={dashboard} />
</SoloPanelContext.Provider>
);
}
export interface SoloPanelNotFoundProps {
/**
* Controls panel not found error message
*/
singleMatch: boolean;
/**
* Used to check if variables are loading
*/
dashboard: DashboardScene;
}
export function SoloPanelNotFound({ singleMatch, dashboard }: SoloPanelNotFoundProps) {
const context = useSoloPanelContext()!;
const [state, setState] = useState({ matchFound: false, isLoading: true });
useEffect(() => {
// This effect fires before any child layout starts rendering and checking if their panels match the solo panel filter
// We need this polling here to check if any solo panel has matched or if any layout has marked the context as loading (for repeated panels)
const cancelTimeout = setInterval(() => {
setState({ matchFound: context.matchFound, isLoading: isAnyVariableLoading(dashboard) });
}, 500);
return () => clearInterval(cancelTimeout);
}, [context, dashboard]);
if (state.matchFound || context.matchFound) {
return null;
}
if (state.isLoading) {
return <Spinner />;
}
return (
<Box
backgroundColor={'primary'}
borderColor={'weak'}
borderStyle={'solid'}
padding={2}
borderRadius={'default'}
display={'flex'}
justifyContent={'center'}
alignItems={'center'}
>
{singleMatch && <Trans i18nKey="dashboard.view-panel.not-found">Panel not found</Trans>}
{!singleMatch && <Trans i18nKey="dashboard.search-panel.no-match">No panels matching</Trans>}
</Box>
);
}
function isAnyVariableLoading(scene: DashboardScene) {
const variables = scene.state.$variables;
if (!variables || !variables.isActive) {
return true;
}
return variables.state.variables.some((variable) => variable.state.loading);
}

View File

@ -13,7 +13,7 @@ import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { ShareModal } from '../sharing/ShareModal';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { findVizPanelByPathId } from '../utils/pathId';
import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getEditPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
import { getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
@ -50,15 +50,10 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
keybindings.addBinding({
key: 'v',
onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => {
if (scene.state.viewPanelScene) {
locationService.push(
locationUtil.getUrlForPartial(locationService.getLocation(), {
viewPanel: undefined,
})
);
if (scene.state.viewPanel) {
locationService.partial({ viewPanel: undefined });
} else {
const url = locationUtil.stripBaseFromUrl(getViewPanelUrl(vizPanel));
locationService.push(url);
locationService.partial({ viewPanel: vizPanel.getPathId(), editPanel: undefined });
}
}),
});

View File

@ -18,7 +18,6 @@ import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getOptions } from './AutoGridItemEditor';
import { AutoGridItemRenderer } from './AutoGridItemRenderer';
@ -130,8 +129,6 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
this.setState({ repeatedPanels });
this._prevRepeatValues = values;
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public getPanelCount() {

View File

@ -7,6 +7,7 @@ import { useStyles2 } from '@grafana/ui';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { useDashboardState } from '../../utils/utils';
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
import { getIsLazy } from '../layouts-shared/utils';
import { AutoGridItem } from './AutoGridItem';
@ -19,7 +20,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] =
useIsConditionallyHidden(model);
const styles = useStyles2(getStyles);
const soloPanelContext = useSoloPanelContext();
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
const Wrapper = useMemo(
@ -77,6 +78,10 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
[conditionalRenderingClass, conditionalRenderingOverlay, isLazy, key, model.containerRef, styles]
);
if (soloPanelContext) {
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels]);
}
if (isConditionallyHidden && !isEditing) {
return null;
}

View File

@ -6,6 +6,7 @@ import { useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
@ -18,6 +19,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
const { layoutOrchestrator, isEditing } = useDashboardState(model);
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
const { fillScreen } = layoutManager.useState();
const soloPanelContext = useSoloPanelContext();
if (isHidden || !layoutOrchestrator) {
return null;
@ -25,6 +27,10 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
const showCanvasActions = !isRepeatCloneOrChildOf(model) && isEditing;
if (soloPanelContext) {
return children.map((item) => <item.Component key={item.state.key} model={item} />);
}
return (
<div
className={cx(styles.container, fillScreen && styles.containerFillScreen, isEditing && styles.containerEditing)}

View File

@ -20,7 +20,6 @@ import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
import { DashboardGridItemRenderer } from './DashboardGridItemRenderer';
@ -209,8 +208,6 @@ export class DashboardGridItem
}
this._prevRepeatValues = values;
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public handleVariableName() {

View File

@ -5,10 +5,13 @@ import { config } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
export function DashboardGridItemRenderer({ model }: SceneComponentProps<DashboardGridItem>) {
const { repeatedPanels = [], itemHeight, variableName, body } = model.useState();
const soloPanelContext = useSoloPanelContext();
const layoutStyle = useLayoutStyle(
model.getRepeatDirection(),
model.getPanelCount(),
@ -16,6 +19,10 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps<Dashboa
itemHeight ?? 10
);
if (soloPanelContext) {
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels]);
}
if (!variableName) {
return (
<div className={panelWrapper} ref={model.containerRef}>

View File

@ -42,6 +42,7 @@ import {
getLayoutOrchestratorFor,
getDashboardSceneFor,
} from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste';
@ -565,6 +566,11 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
const hasClonedParents = isRepeatCloneOrChildOf(model);
const styles = useStyles2(getStyles);
const showCanvasActions = isEditing && config.featureToggles.dashboardNewLayouts && !hasClonedParents;
const soloPanelContext = useSoloPanelContext();
if (soloPanelContext) {
return children.map((child) => <child.Component model={child} key={child.state.key!} />);
}
// If we are top level layout and we have no children, show empty state
if (model.parent === dashboard && children.length === 0) {
@ -585,6 +591,20 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
);
}
const OriginalSceneGridRowRenderer = SceneGridRow.Component;
// @ts-expect-error
SceneGridRow.Component = SceneGridRowRenderer;
function SceneGridRowRenderer({ model }: SceneComponentProps<SceneGridRow>) {
const soloPanelContext = useSoloPanelContext();
if (soloPanelContext) {
return model.state.children.map((child) => <child.Component model={child} key={child.state.key!} />);
}
return <OriginalSceneGridRowRenderer model={model} />;
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({

View File

@ -14,7 +14,6 @@ import {
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
interface RowRepeaterBehaviorState extends SceneObjectState {
variableName: string;
@ -172,9 +171,6 @@ export class RowRepeaterBehavior extends SceneObjectBase<RowRepeaterBehaviorStat
}
updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat.state.key!);
// Used from dashboard url sync
this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
}
public removeBehavior() {

View File

@ -12,6 +12,7 @@ import { useIsConditionallyHidden } from '../../conditional-rendering/useIsCondi
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState, useInterpolatedTitle } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useSoloPanelContext } from '../SoloPanelContext';
import { RowItem } from './RowItem';
@ -28,6 +29,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const clearStyles = useStyles2(clearButtonStyles);
const isTopLevel = model.parent?.parent instanceof DashboardScene;
const pointerDistance = usePointerDistance();
const soloPanelContext = useSoloPanelContext();
const myIndex = rows.findIndex((row) => row === model);
@ -45,6 +47,10 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
return null;
}
if (soloPanelContext) {
return <layout.Component model={layout} />;
}
return (
<Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isDraggable}>
{(dragProvided, dragSnapshot) => (

View File

@ -7,7 +7,6 @@ import { Spinner } from '@grafana/ui';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { dashboardLog, getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { RowItem } from './RowItem';
import { RowsLayoutManager } from './RowsLayoutManager';
@ -118,7 +117,6 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co
}
row.setState({ repeatedRows: clonedRows });
row.publishEvent(new DashboardRepeatsProcessedEvent({ source: row }), true);
}
/**

View File

@ -9,6 +9,7 @@ import { Button, useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { useClipboardState } from '../layouts-shared/useClipboardState';
import { RowItem } from './RowItem';
@ -20,6 +21,11 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const { isEditing } = useDashboardState(model);
const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState();
const soloPanelContext = useSoloPanelContext();
if (soloPanelContext) {
return rows.map((row) => <RowWrapper row={row} manager={model} key={row.state.key!} />);
}
const isClone = isRepeatCloneOrChildOf(model);

View File

@ -9,7 +9,6 @@ import { Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { dashboardLog, getMultiVariableValues } from '../../utils/utils';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { TabItem } from './TabItem';
import { TabsLayoutManager } from './TabsLayoutManager';
@ -99,7 +98,6 @@ export function performTabRepeats(variable: MultiValueVariable, tab: TabItem, co
const clonedTabs = createTabRepeats({ values, texts, variable, tab });
tab.setState({ repeatedTabs: clonedTabs });
tab.publishEvent(new DashboardRepeatsProcessedEvent({ source: tab }), true);
}
/**

View File

@ -10,6 +10,7 @@ import { Button, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
import { useClipboardState } from '../layouts-shared/useClipboardState';
@ -26,6 +27,11 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
const { isEditing } = dashboard.useState();
const { hasCopiedTab } = useClipboardState();
const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(currentTab);
const soloPanelContext = useSoloPanelContext();
if (soloPanelContext) {
return <layout.Component model={layout} />;
}
const isClone = isRepeatCloneOrChildOf(model);

View File

@ -10,10 +10,10 @@ import { StarButton } from './actions/StarButton';
import { getDynamicActions, renderActionElements } from './utils';
export const LeftActions = ({ dashboard }: { dashboard: DashboardScene }) => {
const { editview, editPanel, isEditing, uid, meta, viewPanelScene } = dashboard.useState();
const { editview, editPanel, isEditing, uid, meta, viewPanel } = dashboard.useState();
const hasEditView = Boolean(editview);
const isViewingPanel = Boolean(viewPanelScene);
const isViewingPanel = Boolean(viewPanel);
const isEditingDashboard = Boolean(isEditing);
const isEditingPanel = Boolean(editPanel);
const hasUid = Boolean(uid);

View File

@ -27,7 +27,7 @@ import { ToolbarActionProps } from './types';
import { getDynamicActions, renderActionElements } from './utils';
export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
const { editPanel, editable, editview, isEditing, uid, meta, viewPanelScene } = dashboard.useState();
const { editPanel, editable, editview, isEditing, uid, meta, viewPanel } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const styles = useStyles2(getStyles);
@ -37,7 +37,7 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
const isEditingDashboard = Boolean(isEditing);
const hasEditView = Boolean(editview);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene);
const isViewingPanel = Boolean(viewPanel);
const isEditingLibraryPanel = isEditingPanel && isLibraryPanel(editPanel!.state.panelRef.resolve());
const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditingDashboard && isShowingDashboard;

View File

@ -1,10 +0,0 @@
import { BusEventWithPayload } from '@grafana/data';
import { SceneObject } from '@grafana/scenes';
export interface DashboardRepeatsProcessedEventPayload {
source: SceneObject;
}
export class DashboardRepeatsProcessedEvent extends BusEventWithPayload<DashboardRepeatsProcessedEventPayload> {
public static type = 'dashboard-repeats-processed';
}

View File

@ -4,9 +4,9 @@ import { useEffect } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { t } from '@grafana/i18n';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Alert, Box, Spinner, useStyles2 } from '@grafana/ui';
import { Alert, Box, useStyles2 } from '@grafana/ui';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@ -15,8 +15,7 @@ import { DashboardRoutes } from 'app/types/dashboard';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { DashboardScene } from '../scene/DashboardScene';
import { useSoloPanel } from './useSoloPanel';
import { SoloPanelContextProvider, useDefineSoloPanelContext } from '../scene/SoloPanelContext';
export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, { panelId: string }> {}
@ -61,30 +60,26 @@ export function SoloPanelPage({ queryParams }: Props) {
export default SoloPanelPage;
export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) {
const [panel, error] = useSoloPanel(dashboard, panelId);
const { controls } = dashboard.useState();
const { controls, body } = dashboard.useState();
const refreshPicker = controls?.useState()?.refreshPicker;
const styles = useStyles2(getStyles);
const soloPanelContext = useDefineSoloPanelContext(panelId)!;
useEffect(() => {
return refreshPicker?.activate();
}, [refreshPicker]);
const dashDeactivate = dashboard.activate();
const refreshDeactivate = refreshPicker?.activate();
if (error) {
return <Alert title={error} />;
}
if (!panel) {
return (
<span>
<Trans i18nKey="dashboard-scene.solo-panel-page.loading">Loading</Trans> <Spinner />
</span>
);
}
return () => {
dashDeactivate();
refreshDeactivate?.();
};
}, [dashboard, refreshPicker]);
return (
<div className={styles.container}>
<panel.Component model={panel} />
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
<body.Component model={body} />
</SoloPanelContextProvider>
</div>
);
}

View File

@ -1,133 +0,0 @@
import { renderHook } from '@testing-library/react';
import { DataSourceRef } from '@grafana/schema';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../utils/utils';
import { useSoloPanel } from './useSoloPanel';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
get: async (ref: DataSourceRef) => {
// Mocking the build in Grafana data source to avoid annotations data layer errors.
return {
id: 1,
uid: '-- Grafana --',
name: 'grafana',
type: 'grafana',
meta: {
id: 'grafana',
},
};
},
}),
}));
describe('useSoloPanel', () => {
it('should return undefined panel and error when panel is not found', () => {
const { dashboard } = setup();
const { result } = renderHook(() => useSoloPanel(dashboard, 'foo-key'));
expect(result.current[0]).toBeUndefined();
expect(result.current[1]).toBe('Panel not found');
});
it('should return the panel when panel is found', () => {
const { dashboard } = setup();
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-1'));
const panel = findVizPanelByKey(dashboard, 'panel-1');
expect(result.current[0]).toEqual(panel);
expect(result.current[1]).toBeUndefined();
});
it('should return the cloned panel when panel is found', () => {
const { dashboard } = setup();
const { result } = renderHook(() => useSoloPanel(dashboard, 'A$panel-1'));
const panel = findVizPanelByKey(dashboard, 'panel-1');
expect(result.current[0]).not.toBe(panel);
expect(result.current[1]).toBeUndefined();
});
it('should return error when panelId correspond to a non VizPanel', () => {
const { dashboard } = setup();
const { result } = renderHook(() => useSoloPanel(dashboard, 'panel-2'));
expect(result.current[0]).toBeUndefined();
expect(result.current[1]).toBe('Panel not found');
});
});
const setup = () => {
const dashboard = transformSaveModelToScene({ dashboard: TEST_DASHBOARD, meta: {} });
return { dashboard };
};
const TEST_DASHBOARD = {
title: 'Scenes/PanelEdit/Queries: Edit',
annotations: {
list: [],
},
editable: true,
fiscalYearStartMonth: 0,
graphTooltip: 0,
id: 2378,
links: [],
liveNow: false,
panels: [
{
type: 'timeseries',
datasource: 'prometheus',
fieldConfig: {
defaults: {
custom: {},
},
overrides: [],
},
gridPos: {
h: 9,
w: 24,
x: 0,
y: 0,
},
id: 1,
options: {
colorMode: 'background',
graphMode: 'area',
justifyMode: 'auto',
orientation: 'auto',
reduceOptions: {
calcs: ['lastNotNull', 'last', 'first', 'min', 'max', 'mean', 'sum', 'count'],
fields: '',
values: false,
},
text: {},
},
pluginVersion: '8.0.3',
},
{
id: 2,
type: 'row',
},
],
refresh: '',
schemaVersion: 39,
tags: [],
templating: {
list: [],
},
time: {
from: 'now-6h',
to: 'now',
},
timepicker: {},
timezone: '',
uid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
version: 6,
weekStart: '',
};

View File

@ -1,70 +0,0 @@
import { useState, useEffect } from 'react';
import { VizPanel, UrlSyncManager } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardRepeatsProcessedEvent } from '../scene/types/DashboardRepeatsProcessedEvent';
import { containsPathIdSeparator, findVizPanelByPathId } from '../utils/pathId';
export function useSoloPanel(dashboard: DashboardScene, pathId: string): [VizPanel | undefined, string | undefined] {
const [panel, setPanel] = useState<VizPanel>();
const [error, setError] = useState<string | undefined>();
useEffect(() => {
const urlSyncManager = new UrlSyncManager();
urlSyncManager.initSync(dashboard);
const cleanUp = dashboard.activate();
let panel: VizPanel | null = null;
try {
panel = findVizPanelByPathId(dashboard, pathId);
} catch (e) {
// do nothing, just the panel is not found or not a VizPanel
}
if (panel) {
activateParents(panel);
setPanel(panel);
} else if (containsPathIdSeparator(pathId)) {
findRepeatClone(dashboard, pathId).then((panel) => {
if (panel) {
setPanel(panel);
} else {
setError('Panel not found');
}
});
} else {
setError('Panel not found');
}
return cleanUp;
}, [dashboard, pathId]);
return [panel, error];
}
function activateParents(panel: VizPanel) {
let parent = panel.parent;
while (parent && !parent.isActive) {
parent.activate();
parent = parent.parent;
}
}
function findRepeatClone(dashboard: DashboardScene, pathId: string): Promise<VizPanel | undefined> {
return new Promise((resolve) => {
dashboard.subscribeToEvent(DashboardRepeatsProcessedEvent, () => {
const panel = findVizPanelByPathId(dashboard, pathId);
if (panel) {
resolve(panel);
} else {
// If rows are repeated they could add new panel repeaters that needs to be activated
dashboard.state.body.activateRepeaters?.();
}
});
dashboard.state.body.activateRepeaters?.();
});
}

View File

@ -5223,6 +5223,9 @@
"save-json-to-file": "Save JSON to file",
"see-docs": "See <2>documentation</2> for more information about provisioning."
},
"search-panel": {
"no-match": "No panels matching"
},
"share-public-dashboard-loader": {
"loading-configuration": "Loading configuration"
},
@ -5495,6 +5498,9 @@
"view-json-modal": {
"title-json": "JSON"
},
"view-panel": {
"not-found": "Panel not found"
},
"visualization-button": {
"aria-label-change-visualization": "Change visualization",
"aria-label-close": "Close options pane",
@ -6135,9 +6141,6 @@
"share-button": {
"aria-label-sharedropdownmenu": "Toggle share menu"
},
"solo-panel-page": {
"loading": "Loading"
},
"support-snapshot-service": {
"description": {
"dashboard-troubleshoot-visualization-issues": "Dashboard JSON used to help troubleshoot visualization issues"
@ -10712,10 +10715,6 @@
"could-anything-matching-query": "Could not find anything matching your query"
}
},
"panel-search": {
"no-matches": "No matches found",
"unsupported-layout": "Unsupported layout"
},
"panel-type-filter": {
"clear-button": "Clear types",
"select-aria-label": "Panel type filter",