diff --git a/config/loki-config.yaml b/config/loki-config.yaml index 94a96e0b7..4b3061e65 100644 --- a/config/loki-config.yaml +++ b/config/loki-config.yaml @@ -21,7 +21,6 @@ frontend: pattern_ingester: enabled: true metric_aggregation: - enabled: true loki_address: 127.0.0.1:3100 limits_config: max_global_streams_per_user: 0 diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index e6a46e6e3..16ecfc209 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -22,7 +22,6 @@ services: extra_hosts: - 'host.docker.internal:host-gateway' loki: -# image: grafana/loki:main-12cd12a image: grafana/loki:3.3.0 environment: LOG_CLUSTER_DEPTH: '8' diff --git a/src/Components/Panels/PanelMenu.tsx b/src/Components/Panels/PanelMenu.tsx new file mode 100644 index 000000000..c441bab15 --- /dev/null +++ b/src/Components/Panels/PanelMenu.tsx @@ -0,0 +1,192 @@ +import { DataFrame, GrafanaTheme2, PanelMenuItem } from '@grafana/data'; +import { + SceneComponentProps, + SceneCSSGridItem, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, + VizPanelMenu, +} from '@grafana/scenes'; +import React from 'react'; +import { css } from '@emotion/css'; +import { onExploreLinkClick } from '../ServiceScene/GoToExploreButton'; +import { IndexScene } from '../IndexScene/IndexScene'; +import { getQueryRunnerFromChildren } from '../../services/scenes'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; +import { logger } from '../../services/logger'; +import { AddToExplorationButton } from '../ServiceScene/Breakdowns/AddToExplorationButton'; +import { getPluginLinkExtensions } from '@grafana/runtime'; +import { ExtensionPoints } from '../../services/extensions/links'; + +const ADD_TO_INVESTIGATION_MENU_TEXT = 'Add to investigation'; +const ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT = 'Add to investigation div'; + +interface PanelMenuState extends SceneObjectState { + body?: VizPanelMenu; + frame?: DataFrame; + labelName?: string; + fieldName?: string; + addToExplorations?: AddToExplorationButton; +} + +/** + * @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined + */ +export class PanelMenu extends SceneObjectBase implements VizPanelMenu, SceneObject { + constructor(state: Partial) { + super(state); + this.addActivationHandler(() => { + this.setState({ + addToExplorations: new AddToExplorationButton({ + labelName: this.state.labelName, + fieldName: this.state.fieldName, + frame: this.state.frame, + }), + }); + + // @todo rewrite the AddToExplorationButton + // Manually activate scene + this.state.addToExplorations?.activate(); + + const items: PanelMenuItem[] = [ + { + text: 'Explore', + iconClassName: 'compass', + href: getExploreLink(this), + onClick: () => onExploreLinkClickTracking(), + }, + ]; + + this.setState({ + body: new VizPanelMenu({ + items, + }), + }); + + this.state.addToExplorations?.subscribeToState(() => { + subscribeToAddToExploration(this); + }); + }); + } + + addItem(item: PanelMenuItem): void { + if (this.state.body) { + this.state.body.addItem(item); + } + } + setItems(items: PanelMenuItem[]): void { + if (this.state.body) { + this.state.body.setItems(items); + } + } + + public static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + + if (body) { + return ; + } + + return <>; + }; +} + +const getExploreLink = (sceneRef: SceneObject) => { + const indexScene = sceneGraph.getAncestor(sceneRef, IndexScene); + const $data = sceneGraph.getData(sceneRef); + let queryRunner = getQueryRunnerFromChildren($data)[0]; + + // If we don't have a query runner, then our panel is within a SceneCSSGridItem, we need to get the query runner from there + if (!queryRunner) { + const sceneGridItem = sceneGraph.getAncestor(sceneRef, SceneCSSGridItem); + const queryProvider = sceneGraph.getData(sceneGridItem); + + if (queryProvider instanceof SceneQueryRunner) { + queryRunner = queryProvider; + } else { + logger.error(new Error('query provider not found!')); + } + } + const uninterpolatedExpr: string | undefined = queryRunner.state.queries[0].expr; + const expr = sceneGraph.interpolate(sceneRef, uninterpolatedExpr); + + return onExploreLinkClick(indexScene, expr); +}; + +const onExploreLinkClickTracking = () => { + reportAppInteraction(USER_EVENTS_PAGES.all, USER_EVENTS_ACTIONS.all.open_in_explore_menu_clicked); +}; + +const getInvestigationLink = (addToExplorations: AddToExplorationButton) => { + const links = getPluginLinkExtensions({ + extensionPointId: ExtensionPoints.MetricExploration, + context: addToExplorations.state.context, + }); + + return links.extensions[0]; +}; + +const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => { + const link = getInvestigationLink(addToExplorations); + if (link && link.onClick) { + link.onClick(event); + } +}; + +function subscribeToAddToExploration(exploreLogsVizPanelMenu: PanelMenu) { + const addToExplorationButton = exploreLogsVizPanelMenu.state.addToExplorations; + if (addToExplorationButton) { + const link = getInvestigationLink(addToExplorationButton); + + const existingMenuItems = exploreLogsVizPanelMenu.state.body?.state.items ?? []; + + const existingAddToExplorationLink = existingMenuItems.find((item) => item.text === ADD_TO_INVESTIGATION_MENU_TEXT); + + if (link) { + if (!existingAddToExplorationLink) { + exploreLogsVizPanelMenu.state.body?.addItem({ + text: ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT, + type: 'divider', + }); + exploreLogsVizPanelMenu.state.body?.addItem({ + text: ADD_TO_INVESTIGATION_MENU_TEXT, + iconClassName: 'plus-square', + onClick: (e) => onAddToInvestigationClick(e, addToExplorationButton), + }); + } else { + if (existingAddToExplorationLink) { + exploreLogsVizPanelMenu.state.body?.setItems( + existingMenuItems.filter( + (item) => + item.text !== ADD_TO_INVESTIGATION_MENU_TEXT && item.text !== ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT + ) + ); + } + } + } + } +} + +export const getPanelWrapperStyles = (theme: GrafanaTheme2) => { + return { + panelWrapper: css({ + width: '100%', + height: '100%', + label: 'panel-wrapper', + display: 'flex', + + // @todo remove this wrapper and styles when core changes are introduced in ??? + // Need more specificity to override core style + 'button.show-on-hover': { + opacity: 1, + visibility: 'visible', + background: 'none', + '&:hover': { + background: theme.colors.secondary.shade, + }, + }, + }), + }; +}; diff --git a/src/Components/ServiceScene/Breakdowns/AddToExplorationButton.tsx b/src/Components/ServiceScene/Breakdowns/AddToExplorationButton.tsx index 18f400029..daec921c8 100644 --- a/src/Components/ServiceScene/Breakdowns/AddToExplorationButton.tsx +++ b/src/Components/ServiceScene/Breakdowns/AddToExplorationButton.tsx @@ -11,9 +11,10 @@ import LokiLogo from '../../../img/logo.svg'; export interface AddToExplorationButtonState extends SceneObjectState { frame?: DataFrame; - ds?: DataSourceWithBackend; labelName?: string; fieldName?: string; + + ds?: DataSourceWithBackend; context?: ExtensionContext; queries: DataQuery[]; @@ -45,9 +46,13 @@ export class AddToExplorationButton extends SceneObjectBase { - this.getQueries(); - this.getContext(); + this.subscribeToState((newState, prevState) => { + if (!this.state.queries.length) { + this.getQueries(); + } + if (!this.state.context && this.state.queries.length) { + this.getContext(); + } }) ); }; diff --git a/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx index e9a5ba25f..8557a3ebc 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldValuesBreakdownScene.tsx @@ -18,7 +18,7 @@ import { DataQueryError, LoadingState } from '@grafana/data'; import { LayoutSwitcher } from './LayoutSwitcher'; import { getQueryRunner } from '../../../services/panel'; import { ByFrameRepeater } from './ByFrameRepeater'; -import { Alert, DrawStyle, LoadingPlaceholder } from '@grafana/ui'; +import { Alert, DrawStyle, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { buildFieldsQueryString, getFilterBreakdownValueScene, getParserForField } from '../../../services/fields'; import { getLabelValue } from './SortByScene'; import { VAR_FIELDS, VAR_METADATA } from '../../../services/variables'; @@ -31,6 +31,7 @@ import { getDetectedFieldsFrame, ServiceScene } from '../ServiceScene'; import { DEFAULT_SORT_BY } from '../../../services/sorting'; import { getFieldGroupByVariable, getFieldsVariable } from '../../../services/variableGetters'; import { LokiQuery } from '../../../services/lokiQuery'; +import { PanelMenu, getPanelWrapperStyles } from '../../Panels/PanelMenu'; export interface FieldValuesBreakdownSceneState extends SceneObjectState { body?: (LayoutSwitcher & SceneObject) | (SceneReactObject & SceneObject); @@ -55,8 +56,9 @@ export class FieldValuesBreakdownScene extends SceneObjectBase) => { const { body } = model.useState(); + const styles = useStyles2(getPanelWrapperStyles); if (body) { - return <>{body && }; + return {body && }; } return ; @@ -180,7 +182,7 @@ export class FieldValuesBreakdownScene extends SceneObjectBase) => { const { body } = model.useState(); + const styles = useStyles2(getPanelWrapperStyles); if (body) { - return <>{body && }; + return {body && }; } return ; diff --git a/src/Components/ServiceScene/Breakdowns/LabelValuesBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/LabelValuesBreakdownScene.tsx index 63a0bf7f8..94ce21afd 100644 --- a/src/Components/ServiceScene/Breakdowns/LabelValuesBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/LabelValuesBreakdownScene.tsx @@ -14,7 +14,7 @@ import { } from '@grafana/scenes'; import { LayoutSwitcher } from './LayoutSwitcher'; import { getLabelValue } from './SortByScene'; -import { Alert, DrawStyle, LoadingPlaceholder, StackingMode } from '@grafana/ui'; +import { Alert, DrawStyle, LoadingPlaceholder, StackingMode, useStyles2 } from '@grafana/ui'; import { getQueryRunner, setLevelColorOverrides } from '../../../services/panel'; import { getSortByPreference } from '../../../services/store'; import { AppEvents, DataQueryError, LoadingState } from '@grafana/data'; @@ -31,6 +31,7 @@ import { DEFAULT_SORT_BY } from '../../../services/sorting'; import { buildLabelsQuery, LABEL_BREAKDOWN_GRID_TEMPLATE_COLUMNS } from '../../../services/labels'; import { getAppEvents } from '@grafana/runtime'; import { getLabelGroupByVariable } from '../../../services/variableGetters'; +import { PanelMenu, getPanelWrapperStyles } from '../../Panels/PanelMenu'; type DisplayError = DataQueryError & { displayed: boolean }; type DisplayErrors = Record; @@ -184,6 +185,7 @@ export class LabelValuesBreakdownScene extends SceneObjectBase) => { const { body } = model.useState(); + const styles = useStyles2(getPanelWrapperStyles); if (body) { - return <>{body && }; + return {body && }; } return ; diff --git a/src/Components/ServiceScene/Breakdowns/LabelsAggregatedBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/LabelsAggregatedBreakdownScene.tsx index 830c22eb1..384ca870b 100644 --- a/src/Components/ServiceScene/Breakdowns/LabelsAggregatedBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/LabelsAggregatedBreakdownScene.tsx @@ -13,7 +13,7 @@ import { VizPanel, } from '@grafana/scenes'; import { LayoutSwitcher } from './LayoutSwitcher'; -import { DrawStyle, LoadingPlaceholder, StackingMode } from '@grafana/ui'; +import { DrawStyle, LoadingPlaceholder, StackingMode, useStyles2 } from '@grafana/ui'; import { getQueryRunner, setLevelColorOverrides } from '../../../services/panel'; import { ALL_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE } from '../../../services/variables'; import React from 'react'; @@ -27,7 +27,7 @@ import { getFieldsVariable, getLabelGroupByVariable } from '../../../services/va import { LokiQuery } from '../../../services/lokiQuery'; import { ServiceScene } from '../ServiceScene'; import { DataFrame, LoadingState } from '@grafana/data'; -import { AddToExplorationButton } from './AddToExplorationButton'; +import { PanelMenu, getPanelWrapperStyles } from '../../Panels/PanelMenu'; export interface LabelsAggregatedBreakdownSceneState extends SceneObjectState { body?: LayoutSwitcher; @@ -224,16 +224,15 @@ export class LabelsAggregatedBreakdownScene extends SceneObjectBase) => { const { body } = model.useState(); + const styles = useStyles2(getPanelWrapperStyles); + if (body) { - return <>{body && }; + return {body && }; } return ; diff --git a/src/Components/ServiceScene/GoToExploreButton.tsx b/src/Components/ServiceScene/GoToExploreButton.tsx index e9bb4ab67..658223fd2 100644 --- a/src/Components/ServiceScene/GoToExploreButton.tsx +++ b/src/Components/ServiceScene/GoToExploreButton.tsx @@ -20,23 +20,7 @@ export const GoToExploreButton = ({ exploration }: GoToExploreButtonState) => { USER_EVENTS_PAGES.service_details, USER_EVENTS_ACTIONS.service_details.open_in_explore_clicked ); - const datasource = getDataSource(exploration); - const expr = getQueryExpr(exploration).replace(/\s+/g, ' ').trimEnd(); - const timeRange = sceneGraph.getTimeRange(exploration).state.value; - const displayedFields = getDisplayedFields(exploration); - const visualisationType = getLogsVisualizationType(); - const columns = getUrlColumns(); - const exploreState = JSON.stringify({ - ['loki-explore']: { - range: toURLRange(timeRange.raw), - queries: [{ refId: 'logs', expr, datasource }], - panelsState: { logs: { displayedFields, visualisationType, columns } }, - datasource, - }, - }); - const subUrl = config.appSubUrl ?? ''; - const link = urlUtil.renderUrl(`${subUrl}/explore`, { panes: exploreState, schemaVersion: 1 }); - window.open(link, '_blank'); + onExploreLinkClick(exploration, undefined, true); }; return ( @@ -51,6 +35,35 @@ export const GoToExploreButton = ({ exploration }: GoToExploreButtonState) => { ); }; +export const onExploreLinkClick = (indexScene: IndexScene, expr?: string, open = false) => { + if (!expr) { + expr = getQueryExpr(indexScene); + } + + expr = expr.replace(/\s+/g, ' ').trimEnd(); + + const datasource = getDataSource(indexScene); + const timeRange = sceneGraph.getTimeRange(indexScene).state.value; + const displayedFields = getDisplayedFields(indexScene); + const visualisationType = getLogsVisualizationType(); + const columns = getUrlColumns(); + const exploreState = JSON.stringify({ + ['loki-explore']: { + range: toURLRange(timeRange.raw), + queries: [{ refId: 'logs', expr, datasource }], + panelsState: { logs: { displayedFields, visualisationType, columns } }, + datasource, + }, + }); + const subUrl = config.appSubUrl ?? ''; + const link = urlUtil.renderUrl(`${subUrl}/explore`, { panes: exploreState, schemaVersion: 1 }); + if (open) { + window.open(link, '_blank'); + } + + return link; +}; + function getUrlColumns() { const params = new URLSearchParams(window.location.search); const urlColumns = params.get('urlColumns'); diff --git a/src/Components/ServiceScene/LogsListScene.tsx b/src/Components/ServiceScene/LogsListScene.tsx index a32a18862..92f8ccdcc 100644 --- a/src/Components/ServiceScene/LogsListScene.tsx +++ b/src/Components/ServiceScene/LogsListScene.tsx @@ -231,11 +231,6 @@ export class LogsListScene extends SceneObjectBase { const styles = { panelWrapper: css({ - // If you use hover-header without any header options we must manually hide the remnants, or it shows up as a 1px line in the top-right corner of the viz - '.show-on-hover': { - display: 'none', - }, - // Hack to select internal div 'section > div[class$="panel-content"]': css({ // A components withing the Logs viz sets contain, which creates a new containing block that is not body which breaks the popover menu diff --git a/src/Components/ServiceScene/LogsVolumePanel.tsx b/src/Components/ServiceScene/LogsVolumePanel.tsx index dd0d060fb..7e5072240 100644 --- a/src/Components/ServiceScene/LogsVolumePanel.tsx +++ b/src/Components/ServiceScene/LogsVolumePanel.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { PanelBuilders, SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; -import { LegendDisplayMode, PanelContext, SeriesVisibilityChangeMode } from '@grafana/ui'; +import { LegendDisplayMode, PanelContext, SeriesVisibilityChangeMode, useStyles2 } from '@grafana/ui'; import { getQueryRunner, setLogsVolumeFieldConfigs, syncLogsPanelVisibleSeries } from 'services/panel'; import { buildDataQuery } from 'services/query'; import { LEVEL_VARIABLE_VALUE } from 'services/variables'; @@ -11,6 +11,7 @@ import { toggleLevelFromFilter } from 'services/levels'; import { LoadingState } from '@grafana/data'; import { getFieldsVariable, getLabelsVariable, getLevelsVariable } from '../../services/variableGetters'; import { areArraysEqual } from '../../services/comparison'; +import { PanelMenu, getPanelWrapperStyles } from '../Panels/PanelMenu'; export interface LogsVolumePanelState extends SceneObjectState { panel?: VizPanel; @@ -55,6 +56,7 @@ export class LogsVolumePanel extends SceneObjectBase { .setTitle('Log volume') .setOption('legend', { showLegend: true, calcs: ['sum'], displayMode: LegendDisplayMode.List }) .setUnit('short') + .setMenu(new PanelMenu({})) .setData( getQueryRunner([ buildDataQuery(getTimeSeriesExpr(this, LEVEL_VARIABLE_VALUE, false), { @@ -120,7 +122,12 @@ export class LogsVolumePanel extends SceneObjectBase { if (!panel) { return; } + const styles = useStyles2(getPanelWrapperStyles); - return ; + return ( + + + + ); }; } diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 58e48ea12..47b41f230 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -74,5 +74,6 @@ export const USER_EVENTS_ACTIONS = { }, [USER_EVENTS_PAGES.all]: { interval_too_long: 'interval_too_long', + open_in_explore_menu_clicked: 'open_in_explore_menu_clicked', }, } as const; diff --git a/src/services/fields.ts b/src/services/fields.ts index 20385a13f..efeb63bf1 100644 --- a/src/services/fields.ts +++ b/src/services/fields.ts @@ -26,7 +26,7 @@ import { getDetectedFieldsFrame } from '../Components/ServiceScene/ServiceScene' import { getLogsStreamSelector, getValueFromFieldsFilter } from './variableGetters'; import { LabelType } from './fieldsTypes'; import { logger } from './logger'; -import { AddToExplorationButton } from 'Components/ServiceScene/Breakdowns/AddToExplorationButton'; +import { PanelMenu } from '../Components/Panels/PanelMenu'; export type DetectedLabel = { label: string; @@ -148,10 +148,8 @@ export function getFilterBreakdownValueScene( ) .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) .setOverrides(setLevelColorOverrides) - .setHeaderActions([ - new AddToFiltersButton({ frame, variableName }), - new AddToExplorationButton({ frame, fieldName: getTitle(frame), labelName: labelKey }), - ]); + .setMenu(new PanelMenu({ frame, fieldName: getTitle(frame), labelName: labelKey })) + .setHeaderActions([new AddToFiltersButton({ frame, variableName })]); if (style === DrawStyle.Bars) { panel diff --git a/tests/exploreServicesBreakDown.spec.ts b/tests/exploreServicesBreakDown.spec.ts index d69c1c59f..e48a8e182 100644 --- a/tests/exploreServicesBreakDown.spec.ts +++ b/tests/exploreServicesBreakDown.spec.ts @@ -1040,4 +1040,36 @@ test.describe('explore services breakdown page', () => { new Date(await secondRowTimeCell.textContent()).valueOf() ); }); + + test('panel menu: label name panel should open links in explore', async ({ page, context }) => { + await explorePage.goToLabelsTab(); + await page.getByTestId('data-testid Panel menu detected_level').click(); + + // Open link + await expect(page.getByTestId('data-testid Panel menu item Explore')).toHaveAttribute('href'); + await page.getByTestId('data-testid Panel menu item Explore').click(); + + const newPageCodeEditor = page.getByRole('code').locator('div').filter({ hasText: 'sum(count_over_time({' }).nth(4); + await expect(newPageCodeEditor).toBeInViewport(); + await expect(newPageCodeEditor).toContainText( + 'sum(count_over_time({service_name=`tempo-distributor`} | detected_level != "" [$__auto])) by (detected_level)' + ); + }); + + test('panel menu: label value panel should open links in explore', async ({ page, context }) => { + await explorePage.goToLabelsTab(); + await page.getByLabel(`Select ${levelName}`).click(); + await page.pause(); + await page.getByTestId('data-testid Panel menu error').click(); + + // Open link + await expect(page.getByTestId('data-testid Panel menu item Explore')).toHaveAttribute('href'); + await page.getByTestId('data-testid Panel menu item Explore').click(); + + const newPageCodeEditor = page.getByRole('code').locator('div').filter({ hasText: 'sum(count_over_time({' }).nth(4); + await expect(newPageCodeEditor).toBeInViewport(); + await expect(newPageCodeEditor).toContainText( + 'sum(count_over_time({service_name=`tempo-distributor`} | detected_level != "" [$__auto])) by (detected_level)' + ); + }); });