diff --git a/project-words.txt b/project-words.txt index d9060ebfb..100e2ba1b 100644 --- a/project-words.txt +++ b/project-words.txt @@ -469,4 +469,5 @@ subqueries dbscan svgs Dont +REFID unroute diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index 77fab17f6..6e871f70c 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -161,7 +161,9 @@ function getContentScene(drillDownLabel?: string) { return new ServiceSelectionScene({}); } - return new ServiceScene({ drillDownLabel }); + return new ServiceScene({ + drillDownLabel, + }); } function getVariableSet(initialDatasourceUid: string, initialFilters?: AdHocVariableFilter[]) { diff --git a/src/Components/ServiceScene/ActionBarScene.tsx b/src/Components/ServiceScene/ActionBarScene.tsx new file mode 100644 index 000000000..bdf1a2db1 --- /dev/null +++ b/src/Components/ServiceScene/ActionBarScene.tsx @@ -0,0 +1,110 @@ +import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { Box, Stack, Tab, TabsBar, useStyles2 } from '@grafana/ui'; +import { getExplorationFor } from '../../services/scenes'; +import { getDrilldownSlug, getDrilldownValueSlug, PageSlugs, ValueSlugs } from '../../services/routing'; +import { GoToExploreButton } from './GoToExploreButton'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; +import { ALL_VARIABLE_VALUE, getLabelsVariable } from '../../services/variables'; +import { SERVICE_NAME } from '../ServiceSelectionScene/ServiceSelectionScene'; +import { navigateToDrilldownPage, navigateToIndex } from '../../services/navigate'; +import React from 'react'; +import { ServiceScene, ServiceSceneState } from './ServiceScene'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import { BreakdownViewDefinition, breakdownViewsDefinitions } from './BreakdownViews'; + +export interface ActionBarSceneState extends SceneObjectState {} + +export class ActionBarScene extends SceneObjectBase { + public static Component = ({ model }: SceneComponentProps) => { + const styles = useStyles2(getStyles); + const exploration = getExplorationFor(model); + let currentBreakdownViewSlug = getDrilldownSlug(); + let allowNavToParent = false; + + if (!Object.values(PageSlugs).includes(currentBreakdownViewSlug)) { + const drilldownValueSlug = getDrilldownValueSlug(); + allowNavToParent = true; + if (drilldownValueSlug === ValueSlugs.field) { + currentBreakdownViewSlug = PageSlugs.fields; + } + if (drilldownValueSlug === ValueSlugs.label) { + currentBreakdownViewSlug = PageSlugs.labels; + } + } + + const serviceScene = sceneGraph.getAncestor(model, ServiceScene); + const { loading, $data, ...state } = serviceScene.useState(); + + return ( + +
+ + + +
+ + + {breakdownViewsDefinitions.map((tab, index) => { + return ( + { + if ((tab.value && tab.value !== currentBreakdownViewSlug) || allowNavToParent) { + reportAppInteraction( + USER_EVENTS_PAGES.service_details, + USER_EVENTS_ACTIONS.service_details.action_view_changed, + { + newActionView: tab.value, + previousActionView: currentBreakdownViewSlug, + } + ); + + const serviceScene = sceneGraph.getAncestor(model, ServiceScene); + const variable = getLabelsVariable(serviceScene); + const service = variable.state.filters.find((f) => f.key === SERVICE_NAME); + + if (service?.value) { + navigateToDrilldownPage(tab.value, serviceScene); + } else { + navigateToIndex(); + } + } + }} + /> + ); + })} + +
+ ); + }; +} +const getCounter = (tab: BreakdownViewDefinition, state: ServiceSceneState) => { + switch (tab.value) { + case 'fields': + return state.fieldsCount ?? (state.fields?.filter((l) => l !== ALL_VARIABLE_VALUE) ?? []).length; + case 'patterns': + return state.patternsCount; + case 'labels': + return (state.labels?.filter((l) => l.label !== ALL_VARIABLE_VALUE) ?? []).length; + default: + return undefined; + } +}; + +function getStyles(theme: GrafanaTheme2) { + return { + actions: css({ + [theme.breakpoints.up(theme.breakpoints.values.md)]: { + position: 'absolute', + right: 0, + zIndex: 2, + }, + }), + }; +} diff --git a/src/Components/ServiceScene/BreakdownViews.ts b/src/Components/ServiceScene/BreakdownViews.ts new file mode 100644 index 000000000..c9f4d1808 --- /dev/null +++ b/src/Components/ServiceScene/BreakdownViews.ts @@ -0,0 +1,120 @@ +import { PageSlugs, ValueSlugs } from '../../services/routing'; +import { LogsListScene } from './LogsListScene'; +import { testIds } from '../../services/testIds'; +import { buildLabelValuesBreakdownActionScene, LabelBreakdownScene } from './Breakdowns/LabelBreakdownScene'; +import { FieldsBreakdownScene } from './Breakdowns/FieldsBreakdownScene'; +import { PatternsBreakdownScene } from './Breakdowns/Patterns/PatternsBreakdownScene'; +import { SceneFlexItem, SceneFlexLayout, SceneObject } from '@grafana/scenes'; +import { LogsVolumePanel } from './LogsVolumePanel'; + +interface ValueBreakdownViewDefinition { + displayName: string; + value: ValueSlugs; + testId: string; + getScene: (value: string) => SceneObject; +} + +export interface BreakdownViewDefinition { + displayName: string; + value: PageSlugs; + testId: string; + getScene: (changeFields: (f: string[]) => void) => SceneObject; +} + +export const breakdownViewsDefinitions: BreakdownViewDefinition[] = [ + { + displayName: 'Logs', + value: PageSlugs.logs, + getScene: () => buildLogsListScene(), + testId: testIds.exploreServiceDetails.tabLogs, + }, + { + displayName: 'Labels', + value: PageSlugs.labels, + getScene: () => buildLabelBreakdownActionScene(), + testId: testIds.exploreServiceDetails.tabLabels, + }, + { + displayName: 'Fields', + value: PageSlugs.fields, + getScene: (f) => buildFieldsBreakdownActionScene(f), + testId: testIds.exploreServiceDetails.tabFields, + }, + { + displayName: 'Patterns', + value: PageSlugs.patterns, + getScene: () => buildPatternsScene(), + testId: testIds.exploreServiceDetails.tabPatterns, + }, +]; +export const valueBreakdownViews: ValueBreakdownViewDefinition[] = [ + { + displayName: 'Label', + value: ValueSlugs.label, + getScene: (value: string) => buildLabelValuesBreakdownActionScene(value), + testId: testIds.exploreServiceDetails.tabLabels, + }, + { + displayName: 'Field', + value: ValueSlugs.field, + getScene: (value: string) => buildFieldValuesBreakdownActionScene(value), + testId: testIds.exploreServiceDetails.tabFields, + }, +]; + +function buildPatternsScene() { + return new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new PatternsBreakdownScene({}), + }), + ], + }); +} + +function buildFieldsBreakdownActionScene(changeFieldNumber: (n: string[]) => void) { + return new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new FieldsBreakdownScene({ changeFields: changeFieldNumber }), + }), + ], + }); +} + +function buildFieldValuesBreakdownActionScene(value: string) { + return new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new FieldsBreakdownScene({ value }), + }), + ], + }); +} + +function buildLogsListScene() { + return new SceneFlexLayout({ + direction: 'column', + children: [ + new SceneFlexItem({ + minHeight: 200, + body: new LogsVolumePanel({}), + }), + new SceneFlexItem({ + minHeight: '470px', + height: 'calc(100vh - 500px)', + body: new LogsListScene({}), + }), + ], + }); +} + +function buildLabelBreakdownActionScene() { + return new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new LabelBreakdownScene({}), + }), + ], + }); +} diff --git a/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx index 024fb3c9f..67843c155 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx @@ -287,7 +287,7 @@ export class FieldsBreakdownScene extends SceneObjectBase this.state.search.state.filter ?? ''; return new LayoutSwitcher({ - $data: getQueryRunner(query), + $data: getQueryRunner([query]), options: [ { value: 'single', label: 'Single' }, { value: 'grid', label: 'Grid' }, @@ -522,26 +522,6 @@ function getExpr(field: string) { const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; -export function buildFieldsBreakdownActionScene(changeFieldNumber: (n: string[]) => void) { - return new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new FieldsBreakdownScene({ changeFields: changeFieldNumber }), - }), - ], - }); -} - -export function buildFieldValuesBreakdownActionScene(value: string) { - return new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new FieldsBreakdownScene({ value }), - }), - ], - }); -} - interface SelectLabelActionState extends SceneObjectState { labelName: string; } diff --git a/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx index 89467572e..26dc3393e 100644 --- a/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/LabelBreakdownScene.tsx @@ -218,9 +218,9 @@ export class LabelBreakdownScene extends SceneObjectBase this.state.search.state.filter ?? ''; return new LayoutSwitcher({ - $data: getQueryRunner(query), + $data: getQueryRunner([query]), options: [ { value: 'single', label: 'Single' }, { value: 'grid', label: 'Grid' }, @@ -424,16 +424,6 @@ function getStyles(theme: GrafanaTheme2) { const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; -export function buildLabelBreakdownActionScene() { - return new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new LabelBreakdownScene({}), - }), - ], - }); -} - export function buildLabelValuesBreakdownActionScene(value: string) { return new SceneFlexLayout({ children: [ diff --git a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsBreakdownScene.tsx index 4c2954e72..d902f56f5 100644 --- a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsBreakdownScene.tsx @@ -1,10 +1,11 @@ import { css } from '@emotion/css'; import React from 'react'; -import { DataFrame, dateTime, FieldType, GrafanaTheme2 } from '@grafana/data'; +import { DataFrame, dateTime, GrafanaTheme2 } from '@grafana/data'; import { CustomVariable, SceneComponentProps, + SceneDataState, SceneFlexItem, SceneFlexLayout, sceneGraph, @@ -15,7 +16,7 @@ import { import { Text, useStyles2 } from '@grafana/ui'; import { StatusWrapper } from 'Components/ServiceScene/Breakdowns/StatusWrapper'; import { VAR_LABEL_GROUP_BY } from 'services/variables'; -import { LokiPattern, ServiceScene } from '../../ServiceScene'; +import { ServiceScene } from '../../ServiceScene'; import { IndexScene } from '../../../IndexScene/IndexScene'; import { PatternsFrameScene } from './PatternsFrameScene'; import { PatternsViewTextSearch } from './PatternsViewTextSearch'; @@ -53,8 +54,8 @@ export class PatternsBreakdownScene extends SceneObjectBase) => { - const { body, loading, blockingMessage } = model.useState(); + const { body, loading, blockingMessage, patternFrames } = model.useState(); const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); - const logsByServiceScene = sceneGraph.getAncestor(model, ServiceScene); - const { patterns } = logsByServiceScene.useState(); const styles = useStyles2(getStyles); const timeRangeTooOld = dateTime().diff(timeRange.to, 'hours') >= PATTERNS_MAX_AGE_HOURS; return (
- {!loading && !patterns && ( + {!loading && !patternFrames && (

There are no pattern matches.

@@ -87,9 +86,9 @@ export class PatternsBreakdownScene extends SceneObjectBase )} - {!loading && patterns?.length === 0 && timeRangeTooOld && } - {!loading && patterns?.length === 0 && !timeRangeTooOld && } - {!loading && patterns && patterns.length > 0 && ( + {!loading && patternFrames?.length === 0 && timeRangeTooOld && } + {!loading && patternFrames?.length === 0 && !timeRangeTooOld && } + {!loading && patternFrames && patternFrames.length > 0 && (
{body && }
)} @@ -101,21 +100,25 @@ export class PatternsBreakdownScene extends SceneObjectBase { - if (!areArraysEqual(newState.patterns, prevState.patterns)) { - this.updatePatternFrames(newState.patterns); - } - }) - ); + this._subs.add(serviceScene.state.$patternsData?.subscribeToState(this.onDataChange)); } + private onDataChange = (newState: SceneDataState, prevState: SceneDataState) => { + const newFrames = newState.data?.series; + const prevFrames = prevState.data?.series; + if (!areArraysEqual(newFrames, prevFrames) || this.state.loading) { + this.updatePatternFrames(newFrames); + } + }; + private setBody() { this.setState({ body: new SceneFlexLayout({ @@ -133,74 +136,36 @@ export class PatternsBreakdownScene extends SceneObjectBase { - const timeValues: number[] = []; - const sampleValues: number[] = []; - let sum = 0; - pat.samples.forEach(([time, value]) => { - timeValues.push(time * 1000); - const sample = parseFloat(value); - sampleValues.push(sample); - if (sample > maxValue) { - maxValue = sample; - } - if (sample < minValue) { - minValue = sample; - } - sum += sample; - }); - const dataFrame: DataFrame = { - refId: pat.pattern, - fields: [ - { - name: 'time', - type: FieldType.time, - values: timeValues, - config: {}, - }, - { - name: pat.pattern, - type: FieldType.number, - values: sampleValues, - config: {}, - }, - ], - length: pat.samples.length, - meta: { - preferredVisualisationType: 'graph', - }, - }; - const existingPattern = appliedPatterns?.find((appliedPattern) => appliedPattern.pattern === pat.pattern); - - return { - dataFrame, - pattern: pat.pattern, - sum, - status: existingPattern?.type, - }; - }) - .sort((a, b) => b.sum - a.sum); + return dataFrame.map((dataFrame) => { + const existingPattern = appliedPatterns?.find((appliedPattern) => appliedPattern.pattern === dataFrame.name); + + const sum: number = dataFrame.meta?.custom?.sum; + const patternFrame: PatternFrame = { + dataFrame, + pattern: dataFrame.name ?? '', + sum, + status: existingPattern?.type, + }; + + return patternFrame; + }); } } @@ -240,13 +205,3 @@ function getStyles(theme: GrafanaTheme2) { }), }; } - -export function buildPatternsScene() { - return new SceneFlexLayout({ - children: [ - new SceneFlexItem({ - body: new PatternsBreakdownScene({}), - }), - ], - }); -} diff --git a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsFrameScene.tsx b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsFrameScene.tsx index b8f08d87d..174977f46 100644 --- a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsFrameScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsFrameScene.tsx @@ -44,7 +44,9 @@ export class PatternsFrameScene extends SceneObjectBase public static Component = ({ model }: SceneComponentProps) => { const { body, loading } = model.useState(); const logsByServiceScene = sceneGraph.getAncestor(model, ServiceScene); - const { patterns } = logsByServiceScene.useState(); + const { $patternsData } = logsByServiceScene.useState(); + const patterns = $patternsData?.state.data?.series; + return (
{!loading && patterns && patterns.length > 0 && <>{body && }} @@ -58,7 +60,10 @@ export class PatternsFrameScene extends SceneObjectBase // If the patterns have changed, recalculate the dataframes this._subs.add( sceneGraph.getAncestor(this, ServiceScene).subscribeToState((newState, prevState) => { - if (!areArraysEqual(newState.patterns, prevState.patterns)) { + const newFrame = newState?.$patternsData?.state?.data?.series; + const prevFrame = prevState?.$patternsData?.state?.data?.series; + + if (!areArraysEqual(newFrame, prevFrame)) { const patternsBreakdownScene = sceneGraph.getAncestor(this, PatternsBreakdownScene); this.updatePatterns(patternsBreakdownScene.state.patternFrames); @@ -107,7 +112,8 @@ export class PatternsFrameScene extends SceneObjectBase const patternFrames = patternsBreakdownScene.state.patternFrames; const serviceScene = sceneGraph.getAncestor(this, ServiceScene); - const lokiPatterns = serviceScene.state.patterns; + + const lokiPatterns = serviceScene.state.$patternsData?.state.data?.series; if (!lokiPatterns || !patternFrames) { console.warn('Failed to update PatternsFrameScene body'); return; diff --git a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx new file mode 100644 index 000000000..8ef02d262 --- /dev/null +++ b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsLogsSampleScene.tsx @@ -0,0 +1,266 @@ +import { + PanelBuilders, + SceneComponentProps, + SceneDataProviderResult, + SceneFlexItem, + SceneFlexLayout, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneReactObject, + VizPanel, +} from '@grafana/scenes'; +import React from 'react'; + +import { LoadingState } from '@grafana/data'; +import { Alert, Button } from '@grafana/ui'; +import { + getFieldsVariable, + getLevelsVariable, + getLineFilterVariable, + LOG_STREAM_SELECTOR_EXPR, + PATTERNS_SAMPLE_SELECTOR_EXPR, + VAR_PATTERNS_EXPR, +} from '../../../../services/variables'; +import { buildDataQuery, LokiQuery, renderPatternFilters } from '../../../../services/query'; +import { getQueryRunner } from '../../../../services/panel'; +import { AppliedPattern } from '../../../IndexScene/IndexScene'; +import { PatternsViewTableScene } from './PatternsViewTableScene'; +import { emptyStateStyles } from '../FieldsBreakdownScene'; + +interface PatternsLogsSampleSceneState extends SceneObjectState { + pattern: string; + body?: SceneFlexLayout; +} +export class PatternsLogsSampleScene extends SceneObjectBase { + constructor(state: PatternsLogsSampleSceneState) { + super(state); + + this.addActivationHandler(this.onActivate.bind(this)); + } + + private onActivate() { + if (this.state.body) { + return; + } + + // We start by querying with the users current query context + const queryWithFilters = buildDataQuery(LOG_STREAM_SELECTOR_EXPR); + this.replacePatternsInQuery(queryWithFilters); + + // but if that fails to return results, we fire the query without the filters, instead of showing no-data in the viz + const queryRunnerWithFilters = getQueryRunner([queryWithFilters]); + queryRunnerWithFilters.getResultsStream().subscribe((value) => { + this.onQueryWithFiltersResult(value); + }); + + this.setState({ + body: new SceneFlexLayout({ + direction: 'column', + children: [ + new SceneFlexItem({ + body: undefined, + width: '100%', + height: 0, + }), + new SceneFlexItem({ + height: 300, + width: '100%', + body: PanelBuilders.logs() + .setHoverHeader(true) + .setOption('showLogContextToggle', true) + .setOption('showTime', true) + .setData(queryRunnerWithFilters) + .build(), + }), + ], + }), + }); + } + + private replacePatternsInQuery(queryWithFilters: LokiQuery) { + const pendingPattern: AppliedPattern = { + pattern: this.state.pattern, + type: 'include', + }; + const patternsLine = renderPatternFilters([pendingPattern]); + queryWithFilters.expr = queryWithFilters.expr.replace(VAR_PATTERNS_EXPR, patternsLine); + } + + private clearFilters = () => { + const filterVariable = getFieldsVariable(this); + const lineFilterVariable = getLineFilterVariable(this); + const levelsVariable = getLevelsVariable(this); + filterVariable.setState({ + filters: [], + }); + levelsVariable.setState({ + filters: [], + }); + if (lineFilterVariable.state.value) { + lineFilterVariable.changeValueTo(''); + + const noticeFlexItem = this.getNoticeFlexItem(); + + // The query we just fired is already correct after we clear the filters, we just need to hide the warning, and allow filtering + noticeFlexItem?.setState({ + isHidden: true, + }); + + this.removePatternFromFilterExclusion(); + } + }; + + private removePatternFromFilterExclusion() { + const patternsViewTableScene = sceneGraph.getAncestor(this, PatternsViewTableScene); + const patternsNotMatchingFilters = patternsViewTableScene.state.patternsNotMatchingFilters ?? []; + + const index = patternsNotMatchingFilters.findIndex((pattern) => pattern === this.state.pattern); + + if (index !== -1) { + patternsNotMatchingFilters.splice(index, 1); + // remove this pattern, as they can filter by this pattern again + patternsViewTableScene.setState({ + patternsNotMatchingFilters: patternsNotMatchingFilters, + }); + } + } + + /** + * If the first query with the users filters applied fails, we run another one after removing the filters + * @param value + */ + private onQueryError = (value: SceneDataProviderResult) => { + if ( + (value.data.state === LoadingState.Done && + (value.data.series.length === 0 || value.data.series.every((frame) => frame.length === 0))) || + value.data.state === LoadingState.Error + ) { + // Logging an error so loki folks can debug why some patterns returned from the API seem to fail. + console.error('Pattern sample query returns no results', { + pattern: this.state.pattern, + traceIds: value.data.traceIds, + request: value.data.request, + }); + + this.setWarningMessage( + + This pattern returns no logs. + + ); + + const panelFlexItem = this.getVizFlexItem(); + + // Run another query without the filters so we can still show log lines of what the pattern looks like. + if (panelFlexItem instanceof SceneFlexItem) { + panelFlexItem.setState({ + isHidden: true, + }); + } + } + }; + + private setWarningMessage(reactNode: React.ReactNode) { + const noticeFlexItem = this.getNoticeFlexItem(); + const vizFlexItem = this.getVizFlexItem(); + + if (noticeFlexItem instanceof SceneFlexItem) { + noticeFlexItem.setState({ + isHidden: false, + height: 'auto', + body: new SceneReactObject({ + reactNode: reactNode, + }), + }); + } + return vizFlexItem; + } + + private getNoticeFlexItem() { + const children = this.getFlexItemChildren(); + return children?.[0]; + } + private getVizFlexItem() { + const children = this.getFlexItemChildren(); + return children?.[1]; + } + + private getFlexItemChildren() { + return this.state.body?.state.children; + } + + /** + * Callback to subscription of pattern sample query with all of the current query filters applied. + * If this query fails to return data, we show a warning, and attempt the pattern sample query again without applying the existing filters. + * We also add the pattern to the state of the PatternsTableViewScene so we can hide the filter buttons for this pattern, as including it would break the query + * @param value + */ + private onQueryWithFiltersResult = (value: SceneDataProviderResult) => { + const queryWithoutFilters = buildDataQuery(PATTERNS_SAMPLE_SELECTOR_EXPR); + this.replacePatternsInQuery(queryWithoutFilters); + + const queryRunnerWithoutFilters = getQueryRunner([queryWithoutFilters]); + + // Subscribe to the secondary query, so we can log errors and update the UI + queryRunnerWithoutFilters.getResultsStream().subscribe(this.onQueryError); + + if ( + value.data.state === LoadingState.Done && + (value.data.series.length === 0 || value.data.series.every((frame) => frame.length === 0)) + ) { + const noticeFlexItem = this.getNoticeFlexItem(); + const panelFlexItem = this.getVizFlexItem(); + + // Add a warning notice that the patterns shown will not show up in their current log results due to their existing filters. + if (noticeFlexItem instanceof SceneFlexItem) { + noticeFlexItem.setState({ + isHidden: false, + height: 'auto', + body: new SceneReactObject({ + reactNode: ( + + The logs returned by this pattern do not match the current query filters. + + + ), + }), + }); + } + + // Run another query without the filters so we can still show log lines of what the pattern looks like. + if (panelFlexItem instanceof SceneFlexItem) { + const panel = panelFlexItem.state.body; + if (panel instanceof VizPanel) { + panel?.setState({ + $data: queryRunnerWithoutFilters, + }); + } + } + this.excludeThisPatternFromFiltering(); + } + + if (value.data.state === LoadingState.Error) { + this.onQueryError(value); + } + }; + + private excludeThisPatternFromFiltering() { + const patternsViewTableScene = sceneGraph.getAncestor(this, PatternsViewTableScene); + const patternsThatDontMatchCurrentFilters = patternsViewTableScene.state.patternsNotMatchingFilters ?? []; + + // Add this pattern to the array of patterns that don't match current filters + patternsViewTableScene.setState({ + patternsNotMatchingFilters: [...patternsThatDontMatchCurrentFilters, this.state.pattern], + }); + } + + public static Component({ model }: SceneComponentProps) { + const { body } = model.useState(); + if (body) { + return ; + } + return null; + } +} diff --git a/src/Components/ServiceScene/Breakdowns/Patterns/PatternsTableExpandedRow.tsx b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsTableExpandedRow.tsx new file mode 100644 index 000000000..e0f66b489 --- /dev/null +++ b/src/Components/ServiceScene/Breakdowns/Patterns/PatternsTableExpandedRow.tsx @@ -0,0 +1,30 @@ +import React, { useEffect } from 'react'; +import { PatternsLogsSampleScene } from './PatternsLogsSampleScene'; +import { PatternsTableCellData, PatternsViewTableScene } from './PatternsViewTableScene'; + +interface ExpandedRowProps { + tableViz: PatternsViewTableScene; + row: PatternsTableCellData; +} + +export function PatternsTableExpandedRow({ tableViz, row }: ExpandedRowProps) { + const { expandedRows } = tableViz.useState(); + + const rowScene = expandedRows?.find((scene) => scene.state.key === row.pattern); + + useEffect(() => { + if (!rowScene) { + const newRowScene = buildExpandedRowScene(row.pattern); + tableViz.setState({ expandedRows: [...(tableViz.state.expandedRows ?? []), newRowScene] }); + } + }, [row, tableViz, rowScene]); + + return rowScene ? : null; +} + +function buildExpandedRowScene(pattern: string) { + return new PatternsLogsSampleScene({ + pattern: pattern, + key: pattern, + }); +} diff --git a/src/Components/ServiceScene/Breakdowns/PatternsLogsSampleScene.tsx b/src/Components/ServiceScene/Breakdowns/PatternsLogsSampleScene.tsx index ddc545f5e..24c1e230b 100644 --- a/src/Components/ServiceScene/Breakdowns/PatternsLogsSampleScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/PatternsLogsSampleScene.tsx @@ -48,7 +48,7 @@ export class PatternsLogsSampleScene extends SceneObjectBase { this.onQueryWithFiltersResult(value); }); @@ -198,7 +198,7 @@ export class PatternsLogsSampleScene extends SceneObjectBase { ); } + public getLineFilterScene() { + return this.lineFilterScene; + } + private setStateFromUrl(searchParams: URLSearchParams) { const state: Partial = {}; const selectedLineUrl = searchParams.get('selectedLine'); @@ -137,90 +133,12 @@ export class LogsListScene extends SceneObjectBase { } } - private handleLabelFilter(key: string, value: string, frame: DataFrame | undefined, operator: FilterType) { - // @TODO: NOOP. We need a way to let the user know why this is not possible. - if (key === 'service_name') { - return; - } - const type = frame ? getLabelTypeFromFrame(key, frame) : LabelType.Parsed; - const variableName = type === LabelType.Indexed ? VAR_LABELS : VAR_FIELDS; - addToFilters(key, value, operator, this, variableName); - - reportAppInteraction( - USER_EVENTS_PAGES.service_details, - USER_EVENTS_ACTIONS.service_details.logs_detail_filter_applied, - { - filterType: variableName, - key, - action: operator, - } - ); - } - - public handleLabelFilterClick = (key: string, value: string, frame?: DataFrame) => { - this.handleLabelFilter(key, value, frame, 'toggle'); - }; - - public handleLabelFilterOutClick = (key: string, value: string, frame?: DataFrame) => { - this.handleLabelFilter(key, value, frame, 'exclude'); - }; - - public handleIsFilterLabelActive = (key: string, value: string) => { - const labels = getAdHocFiltersVariable(VAR_LABELS, this); - const fields = getAdHocFiltersVariable(VAR_FIELDS, this); - const levels = getAdHocFiltersVariable(VAR_LEVELS, this); - - const hasKeyValueFilter = (filter: AdHocFiltersVariable | null) => - filter && - filter.state.filters.findIndex( - (filter) => filter.operator === '=' && filter.key === key && filter.value === value - ) >= 0; - - return hasKeyValueFilter(labels) || hasKeyValueFilter(fields) || hasKeyValueFilter(levels); - }; - - public handleFilterStringClick = (value: string) => { - if (this.lineFilterScene) { - this.lineFilterScene.updateFilter(value); - reportAppInteraction( - USER_EVENTS_PAGES.service_details, - USER_EVENTS_ACTIONS.service_details.logs_popover_line_filter, - { - selectionLength: value.length, - } - ); - } - }; - public updateLogsPanel = () => { this.setState({ panel: this.getVizPanel(), }); }; - private getLogsPanel() { - const visualizationType = this.state.visualizationType; - - return new SceneFlexItem({ - height: 'calc(100vh - 220px)', - body: PanelBuilders.logs() - .setTitle('Logs') - .setOption('showTime', true) - // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. - .setOption('onClickFilterLabel', this.handleLabelFilterClick) - // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. - .setOption('onClickFilterOutLabel', this.handleLabelFilterOutClick) - // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. - .setOption('isFilterLabelActive', this.handleIsFilterLabelActive) - // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. - .setOption('onClickFilterString', this.handleFilterStringClick) - .setOption('wrapLogMessage', Boolean(getLogOption('wrapLines'))) - .setOption('showLogContextToggle', true) - .setHeaderActions() - .build(), - }); - } - public setVisualizationType = (type: LogsVisualizationType) => { this.setState({ visualizationType: type, @@ -238,6 +156,7 @@ export class LogsListScene extends SceneObjectBase { private getVizPanel() { this.lineFilterScene = new LineFilterScene(); + return new SceneFlexLayout({ direction: 'column', children: @@ -252,7 +171,10 @@ export class LogsListScene extends SceneObjectBase { new LogOptionsScene(), ], }), - this.getLogsPanel(), + new SceneFlexItem({ + height: 'calc(100vh - 220px)', + body: new LogsPanelScene({}), + }), ] : [ new SceneFlexItem({ @@ -282,23 +204,6 @@ export class LogsListScene extends SceneObjectBase { }; } -export function buildLogsListScene() { - return new SceneFlexLayout({ - direction: 'column', - children: [ - new SceneFlexItem({ - minHeight: 200, - body: new LogsVolumePanel({}), - }), - new SceneFlexItem({ - minHeight: '470px', - height: 'calc(100vh - 500px)', - body: new LogsListScene({}), - }), - ], - }); -} - 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 diff --git a/src/Components/ServiceScene/LogsPanelScene.tsx b/src/Components/ServiceScene/LogsPanelScene.tsx new file mode 100644 index 000000000..9ad4bc473 --- /dev/null +++ b/src/Components/ServiceScene/LogsPanelScene.tsx @@ -0,0 +1,131 @@ +import { + AdHocFiltersVariable, + PanelBuilders, + SceneComponentProps, + sceneGraph, + SceneObjectBase, + SceneObjectState, + VizPanel, +} from '@grafana/scenes'; +import { DataFrame } from '@grafana/data'; +import { getLogOption } from '../../services/store'; +import { LogsPanelHeaderActions } from '../Table/LogsHeaderActions'; +import React from 'react'; +import { LogsListScene } from './LogsListScene'; +import { LoadingPlaceholder } from '@grafana/ui'; +import { addToFilters, FilterType } from './Breakdowns/AddToFiltersButton'; +import { getLabelTypeFromFrame, LabelType } from '../../services/fields'; +import { getAdHocFiltersVariable, VAR_FIELDS, VAR_LABELS, VAR_LEVELS } from '../../services/variables'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; + +interface LogsPanelSceneState extends SceneObjectState { + body?: VizPanel; +} + +export class LogsPanelScene extends SceneObjectBase { + constructor(state: Partial) { + super({ + ...state, + }); + + this.addActivationHandler(this.onActivate.bind(this)); + } + + public onActivate() { + if (!this.state.body) { + this.setState({ + body: this.getLogsPanel(), + }); + } + } + + private getLogsPanel() { + const parentModel = sceneGraph.getAncestor(this, LogsListScene); + const visualizationType = parentModel.state.visualizationType; + + return ( + PanelBuilders.logs() + .setTitle('Logs') + .setOption('showTime', true) + // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. + .setOption('onClickFilterLabel', this.handleLabelFilterClick) + // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. + .setOption('onClickFilterOutLabel', this.handleLabelFilterOutClick) + // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. + .setOption('isFilterLabelActive', this.handleIsFilterLabelActive) + // @ts-expect-error Requires unreleased @grafana/data. Type error, doesn't cause other errors. + .setOption('onClickFilterString', this.handleFilterStringClick) + .setOption('wrapLogMessage', Boolean(getLogOption('wrapLines'))) + .setOption('showLogContextToggle', true) + .setHeaderActions( + + ) + .build() + ); + } + + private handleLabelFilterClick = (key: string, value: string, frame?: DataFrame) => { + this.handleLabelFilter(key, value, frame, 'toggle'); + }; + + private handleLabelFilterOutClick = (key: string, value: string, frame?: DataFrame) => { + this.handleLabelFilter(key, value, frame, 'exclude'); + }; + + private handleIsFilterLabelActive = (key: string, value: string) => { + const labels = getAdHocFiltersVariable(VAR_LABELS, this); + const fields = getAdHocFiltersVariable(VAR_FIELDS, this); + const levels = getAdHocFiltersVariable(VAR_LEVELS, this); + + const hasKeyValueFilter = (filter: AdHocFiltersVariable | null) => + filter && + filter.state.filters.findIndex( + (filter) => filter.operator === '=' && filter.key === key && filter.value === value + ) >= 0; + + return hasKeyValueFilter(labels) || hasKeyValueFilter(fields) || hasKeyValueFilter(levels); + }; + + private handleFilterStringClick = (value: string) => { + const parentModel = sceneGraph.getAncestor(this, LogsListScene); + const lineFilterScene = parentModel.getLineFilterScene(); + if (lineFilterScene) { + lineFilterScene.updateFilter(value); + reportAppInteraction( + USER_EVENTS_PAGES.service_details, + USER_EVENTS_ACTIONS.service_details.logs_popover_line_filter, + { + selectionLength: value.length, + } + ); + } + }; + + private handleLabelFilter(key: string, value: string, frame: DataFrame | undefined, operator: FilterType) { + // @TODO: NOOP. We need a way to let the user know why this is not possible. + if (key === 'service_name') { + return; + } + const type = frame ? getLabelTypeFromFrame(key, frame) : LabelType.Parsed; + const variableName = type === LabelType.Indexed ? VAR_LABELS : VAR_FIELDS; + addToFilters(key, value, operator, this, variableName); + + reportAppInteraction( + USER_EVENTS_PAGES.service_details, + USER_EVENTS_ACTIONS.service_details.logs_detail_filter_applied, + { + filterType: variableName, + key, + action: operator, + } + ); + } + + public static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + if (body) { + return ; + } + return ; + }; +} diff --git a/src/Components/ServiceScene/LogsTableScene.tsx b/src/Components/ServiceScene/LogsTableScene.tsx index 3621a76c7..c14e67aa0 100644 --- a/src/Components/ServiceScene/LogsTableScene.tsx +++ b/src/Components/ServiceScene/LogsTableScene.tsx @@ -8,15 +8,15 @@ import { LogsPanelHeaderActions } from '../Table/LogsHeaderActions'; import { css } from '@emotion/css'; import { addAdHocFilter } from './Breakdowns/AddToFiltersButton'; import { areArraysEqual } from '../../services/comparison'; +import { getLogsPanelFrame } from './ServiceScene'; export class LogsTableScene extends SceneObjectBase { public static Component = ({ model }: SceneComponentProps) => { + const styles = getStyles(); // Get state from parent model const parentModel = sceneGraph.getAncestor(model, LogsListScene); - const { selectedLine, urlColumns, visualizationType } = parentModel.useState(); - - // Get dataFrame const { data } = sceneGraph.getData(model).useState(); + const { selectedLine, urlColumns, visualizationType } = parentModel.useState(); // Get time range const timeRange = sceneGraph.getTimeRange(model); @@ -43,7 +43,7 @@ export class LogsTableScene extends SceneObjectBase { } }; - const styles = getStyles(); + const dataFrame = getLogsPanelFrame(data); return (
@@ -52,7 +52,7 @@ export class LogsTableScene extends SceneObjectBase { title={'Logs'} actions={} > - {data?.series[0] && ( + {dataFrame && ( )} diff --git a/src/Components/ServiceScene/LogsVolumePanel.tsx b/src/Components/ServiceScene/LogsVolumePanel.tsx index efba161c9..8d28e187e 100644 --- a/src/Components/ServiceScene/LogsVolumePanel.tsx +++ b/src/Components/ServiceScene/LogsVolumePanel.tsx @@ -47,11 +47,11 @@ export class LogsVolumePanel extends SceneObjectBase { .setOption('legend', { showLegend: true, calcs: ['sum'], displayMode: LegendDisplayMode.List }) .setUnit('short') .setData( - getQueryRunner( + getQueryRunner([ buildDataQuery(getTimeSeriesExpr(this, LEVEL_VARIABLE_VALUE, false), { legendFormat: `{{${LEVEL_VARIABLE_VALUE}}}`, - }) - ) + }), + ]) ) .setCustomFieldConfig('stacking', { mode: StackingMode.Normal }) .setCustomFieldConfig('fillOpacity', 100) diff --git a/src/Components/ServiceScene/ServiceScene.tsx b/src/Components/ServiceScene/ServiceScene.tsx index bf50b2a41..50979cb4b 100644 --- a/src/Components/ServiceScene/ServiceScene.tsx +++ b/src/Components/ServiceScene/ServiceScene.tsx @@ -1,77 +1,52 @@ -import { css } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { LoadingState, PanelData } from '@grafana/data'; import { SceneComponentProps, + SceneDataProvider, SceneFlexItem, SceneFlexLayout, sceneGraph, - SceneObject, SceneObjectBase, SceneObjectState, + SceneQueryRunner, SceneVariable, VariableDependencyConfig, } from '@grafana/scenes'; -import { Box, LoadingPlaceholder, Stack, Tab, TabsBar, useStyles2 } from '@grafana/ui'; -import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; +import { LoadingPlaceholder } from '@grafana/ui'; import { DetectedLabel, DetectedLabelsResponse, updateParserFromDataFrame } from 'services/fields'; -import { getQueryRunner } from 'services/panel'; -import { buildDataQuery, renderLogQLStreamSelector } from 'services/query'; -import { getDrilldownSlug, getDrilldownValueSlug, PageSlugs, PLUGIN_ID, ValueSlugs } from 'services/routing'; -import { getExplorationFor, getLokiDatasource } from 'services/scenes'; +import { getQueryRunner, getResourceQueryRunner } from 'services/panel'; +import { buildDataQuery, buildResourceQuery } from 'services/query'; +import { getDrilldownSlug, getDrilldownValueSlug, PageSlugs, PLUGIN_ID } from 'services/routing'; +import { getLokiDatasource } from 'services/scenes'; import { - ALL_VARIABLE_VALUE, - getFieldsVariable, getLabelsVariable, LEVEL_VARIABLE_VALUE, LOG_STREAM_SELECTOR_EXPR, VAR_DATASOURCE, VAR_FIELDS, VAR_LABELS, + VAR_LABELS_EXPR, VAR_LEVELS, VAR_PATTERNS, } from 'services/variables'; -import { - buildFieldsBreakdownActionScene, - buildFieldValuesBreakdownActionScene, -} from './Breakdowns/FieldsBreakdownScene'; -import { buildLabelBreakdownActionScene, buildLabelValuesBreakdownActionScene } from './Breakdowns/LabelBreakdownScene'; -import { buildPatternsScene } from './Breakdowns/Patterns/PatternsBreakdownScene'; -import { GoToExploreButton } from './GoToExploreButton'; -import { buildLogsListScene } from './LogsListScene'; -import { testIds } from 'services/testIds'; import { sortLabelsByCardinality } from 'services/filters'; import { SERVICE_NAME } from 'Components/ServiceSelectionScene/ServiceSelectionScene'; import { getMetadataService } from '../../services/metadata'; import { navigateToDrilldownPage, navigateToIndex } from '../../services/navigate'; import { areArraysEqual } from '../../services/comparison'; +import { ActionBarScene } from './ActionBarScene'; +import { breakdownViewsDefinitions, valueBreakdownViews } from './BreakdownViews'; -export interface LokiPattern { - pattern: string; - samples: Array<[number, string]>; -} - -interface BreakdownViewDefinition { - displayName: string; - value: PageSlugs; - testId: string; - getScene: (changeFields: (f: string[]) => void) => SceneObject; -} - -interface ValueBreakdownViewDefinition { - displayName: string; - value: ValueSlugs; - testId: string; - getScene: (value: string) => SceneObject; -} +const LOGS_PANEL_QUERY_REFID = 'logsPanelQuery'; +const PATTERNS_QUERY_REFID = 'patterns'; type MakeOptional = Pick, K> & Omit; export interface ServiceSceneCustomState { fields?: string[]; labels?: DetectedLabel[]; - patterns?: LokiPattern[]; + patternsCount?: number; fieldsCount?: number; loading?: boolean; } @@ -79,6 +54,12 @@ export interface ServiceSceneCustomState { export interface ServiceSceneState extends SceneObjectState, ServiceSceneCustomState { body: SceneFlexLayout | undefined; drillDownLabel?: string; + $data: SceneDataProvider | undefined; + $patternsData: SceneQueryRunner | undefined; +} + +export function getLogsPanelFrame(data: PanelData | undefined) { + return data?.series.find((series) => series.refId === LOGS_PANEL_QUERY_REFID); } export class ServiceScene extends SceneObjectBase { @@ -87,11 +68,12 @@ export class ServiceScene extends SceneObjectBase { onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this), }); - public constructor(state: MakeOptional) { + public constructor(state: MakeOptional) { super({ - body: state.body ?? buildGraphScene(), - $data: getQueryRunner(buildDataQuery(LOG_STREAM_SELECTOR_EXPR)), loading: true, + body: state.body ?? buildGraphScene(), + $data: getServiceSceneQueryRunner(), + $patternsData: getPatternsQueryRunner(), ...state, }); @@ -122,7 +104,11 @@ export class ServiceScene extends SceneObjectBase { this.setState({ $data: undefined, body: undefined, + $patternsData: undefined, + patternsCount: undefined, }); + getMetadataService().setServiceSceneState(this.state); + this._subs.unsubscribe(); // Redirect to root with updated params, which will trigger history push back to index route, preventing empty page or empty service query bugs navigateToIndex(); } @@ -149,24 +135,55 @@ export class ServiceScene extends SceneObjectBase { this.setBreakdownView(); this.setEmptyFiltersRedirection(); + const slug = getDrilldownSlug(); + + // If we don't have a patterns count in the tabs, or we are activating the patterns scene, run the pattern query + if ((this.state.patternsCount === undefined || slug === 'patterns') && !this.state.$patternsData?.state.data) { + this.state.$patternsData?.runQueries(); + } - if (this.state.$data) { - this._subs.add( - this.state.$data?.subscribeToState((newState) => { - if (newState.data?.state === LoadingState.Done) { + this._subs.add( + this.state.$data?.subscribeToState((newState) => { + if (newState.data?.state === LoadingState.Done) { + const logsPanelResponse = getLogsPanelFrame(newState.data); + if (logsPanelResponse) { this.updateFields(); } - }) - ); - } + } + }) + ); + + this._subs.add( + this.state.$patternsData?.subscribeToState((newState) => { + if (newState.data?.state === LoadingState.Done) { + const patternsResponse = newState.data.series; + if (patternsResponse?.length !== undefined) { + // Save the count of patterns to state + this.setState({ + patternsCount: patternsResponse.length, + }); + getMetadataService().setPatternsCount(patternsResponse.length); + } + } + }) + ); this.updateLabels(); - this.updatePatterns(); + const labels = getLabelsVariable(this); + this._subs.add( + labels.subscribeToState((newState, prevState) => { + if (!areArraysEqual(newState.filters, prevState.filters)) { + this.state.$patternsData?.runQueries(); + } + }) + ); + + // Update query runner on manual time range change this._subs.add( sceneGraph.getTimeRange(this).subscribeToState(() => { this.updateLabels(); - this.updatePatterns(); + this.state.$patternsData?.runQueries(); }) ); } @@ -175,7 +192,11 @@ export class ServiceScene extends SceneObjectBase { let stateUpdate: Partial = {}; if (!this.state.$data) { - stateUpdate.$data = getQueryRunner(buildDataQuery(LOG_STREAM_SELECTOR_EXPR)); + stateUpdate.$data = getServiceSceneQueryRunner(); + } + + if (!this.state.$patternsData) { + stateUpdate.$patternsData = getPatternsQueryRunner(); } if (!this.state.body) { @@ -197,7 +218,7 @@ export class ServiceScene extends SceneObjectBase { if (filterVariable.state.filters.length === 0) { return; } - Promise.all([this.updatePatterns(), this.updateLabels()]) + Promise.all([this.updateLabels()]) .finally(() => { // For patterns, we don't want to reload to logs as we allow users to select multiple patterns if (variable.state.name !== VAR_PATTERNS) { @@ -227,24 +248,17 @@ export class ServiceScene extends SceneObjectBase { 'user_identifier', ]; const newState = sceneGraph.getData(this).state; - if (newState.data?.state === LoadingState.Done) { - const frame = newState.data?.series[0]; - if (frame) { - const res = updateParserFromDataFrame(frame, this); - const fields = res.fields.filter((f) => !disabledFields.includes(f)).sort((a, b) => a.localeCompare(b)); - if (!areArraysEqual(fields, this.state.fields)) { - this.setState({ - fields: fields, - loading: false, - }); - } - } else { + const frame = getLogsPanelFrame(newState.data); + if (frame && newState.data?.state === LoadingState.Done) { + const res = updateParserFromDataFrame(frame, this); + const fields = res.fields.filter((f) => !disabledFields.includes(f)).sort((a, b) => a.localeCompare(b)); + if (!areArraysEqual(fields, this.state.fields)) { this.setState({ - fields: [], + fields: fields, loading: false, }); } - } else if (newState.data?.state === LoadingState.Error) { + } else { this.setState({ fields: [], loading: false, @@ -252,43 +266,6 @@ export class ServiceScene extends SceneObjectBase { } } - private async updatePatterns() { - const ds = await getLokiDatasource(this); - if (!ds) { - return; - } - - const timeRange = sceneGraph.getTimeRange(this).state.value; - const labels = getLabelsVariable(this); - const fields = getFieldsVariable(this); - - const excludeLabels = [ALL_VARIABLE_VALUE, LEVEL_VARIABLE_VALUE]; - - const { data } = await ds.getResource( - 'patterns', - { - query: renderLogQLStreamSelector([ - // this will only be the service name for now - ...labels.state.filters, - // only include fields that are an indexed label - ...fields.state.filters.filter( - // we manually add level as a label, but it'll be structured metadata mostly, so we skip it here - (field) => - this.state.labels?.find((label) => label.label === field.key) && !excludeLabels.includes(field.key) - ), - ]), - start: timeRange.from.utc().toISOString(), - end: timeRange.to.utc().toISOString(), - }, - { - headers: { - 'X-Query-Tags': `Source=${PLUGIN_ID}`, - }, - } - ); - this.setState({ patterns: data }); - } - private async updateLabels() { const ds = await getLokiDatasource(this); @@ -296,7 +273,6 @@ export class ServiceScene extends SceneObjectBase { return; } const timeRange = sceneGraph.getTimeRange(this); - const timeRangeValue = timeRange.state.value; const filters = getLabelsVariable(this); @@ -371,153 +347,22 @@ export class ServiceScene extends SceneObjectBase { }; } -const breakdownViewsDefinitions: BreakdownViewDefinition[] = [ - { - displayName: 'Logs', - value: PageSlugs.logs, - getScene: () => buildLogsListScene(), - testId: testIds.exploreServiceDetails.tabLogs, - }, - { - displayName: 'Labels', - value: PageSlugs.labels, - getScene: () => buildLabelBreakdownActionScene(), - testId: testIds.exploreServiceDetails.tabLabels, - }, - { - displayName: 'Fields', - value: PageSlugs.fields, - getScene: (f) => buildFieldsBreakdownActionScene(f), - testId: testIds.exploreServiceDetails.tabFields, - }, - { - displayName: 'Patterns', - value: PageSlugs.patterns, - getScene: () => buildPatternsScene(), - testId: testIds.exploreServiceDetails.tabPatterns, - }, -]; - -const valueBreakdownViews: ValueBreakdownViewDefinition[] = [ - { - displayName: 'Label', - value: ValueSlugs.label, - getScene: (value: string) => buildLabelValuesBreakdownActionScene(value), - testId: testIds.exploreServiceDetails.tabLabels, - }, - { - displayName: 'Field', - value: ValueSlugs.field, - getScene: (value: string) => buildFieldValuesBreakdownActionScene(value), - testId: testIds.exploreServiceDetails.tabFields, - }, -]; - -export interface LogsActionBarState extends SceneObjectState {} - -export class LogsActionBar extends SceneObjectBase { - public static Component = ({ model }: SceneComponentProps) => { - const styles = useStyles2(getStyles); - const exploration = getExplorationFor(model); - let currentBreakdownViewSlug = getDrilldownSlug(); - let allowNavToParent = false; - - if (!Object.values(PageSlugs).includes(currentBreakdownViewSlug)) { - const drilldownValueSlug = getDrilldownValueSlug(); - allowNavToParent = true; - if (drilldownValueSlug === ValueSlugs.field) { - currentBreakdownViewSlug = PageSlugs.fields; - } - if (drilldownValueSlug === ValueSlugs.label) { - currentBreakdownViewSlug = PageSlugs.labels; - } - } - - const getCounter = (tab: BreakdownViewDefinition, state: ServiceSceneState) => { - switch (tab.value) { - case 'fields': - return state.fieldsCount ?? (state.fields?.filter((l) => l !== ALL_VARIABLE_VALUE) ?? []).length; - case 'patterns': - return state.patterns?.length; - case 'labels': - return (state.labels?.filter((l) => l.label !== ALL_VARIABLE_VALUE) ?? []).length; - default: - return undefined; - } - }; - - const serviceScene = sceneGraph.getAncestor(model, ServiceScene); - const { loading, ...state } = serviceScene.useState(); - return ( - -
- - - -
- - - {breakdownViewsDefinitions.map((tab, index) => { - return ( - { - if (tab.value !== currentBreakdownViewSlug || allowNavToParent) { - reportAppInteraction( - USER_EVENTS_PAGES.service_details, - USER_EVENTS_ACTIONS.service_details.action_view_changed, - { - newActionView: tab.value, - previousActionView: currentBreakdownViewSlug, - } - ); - if (tab.value) { - const serviceScene = sceneGraph.getAncestor(model, ServiceScene); - const variable = getLabelsVariable(serviceScene); - const service = variable.state.filters.find((f) => f.key === SERVICE_NAME); - - if (service?.value) { - navigateToDrilldownPage(tab.value, serviceScene); - } else { - navigateToIndex(); - } - } - } - }} - /> - ); - })} - -
- ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - actions: css({ - [theme.breakpoints.up(theme.breakpoints.values.md)]: { - position: 'absolute', - right: 0, - zIndex: 2, - }, - }), - }; -} - function buildGraphScene() { return new SceneFlexLayout({ direction: 'column', children: [ new SceneFlexItem({ ySizing: 'content', - body: new LogsActionBar({}), + body: new ActionBarScene({}), }), ], }); } + +function getPatternsQueryRunner() { + return getResourceQueryRunner([buildResourceQuery(VAR_LABELS_EXPR, 'patterns', { refId: PATTERNS_QUERY_REFID })]); +} + +function getServiceSceneQueryRunner() { + return getQueryRunner([buildDataQuery(LOG_STREAM_SELECTOR_EXPR, { refId: LOGS_PANEL_QUERY_REFID })]); +} diff --git a/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx b/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx index 774555071..57cb287f2 100644 --- a/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx +++ b/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx @@ -79,7 +79,7 @@ export class ServiceSelectionScene extends SceneObjectBase(), ...state, }); @@ -197,13 +197,13 @@ export class ServiceSelectionScene extends SceneObjectBase {/** When services fetched, show how many services are we showing */} {isLogVolumeLoading && } - {!isLogVolumeLoading && <>Showing {serviceCount} service{serviceCount > 1 ? 's' : ''}} + {!isLogVolumeLoading && ( + <> + Showing {serviceCount} service{serviceCount > 1 ? 's' : ''} + + )}
{ constructor(pluginId: string, uid: string) { super(pluginId, uid); @@ -50,7 +70,22 @@ class WrappedLokiDatasource extends RuntimeDataSource { throw new Error('Invalid datasource!'); } - const requestType = request.targets?.[0]?.resource; + // override the target datasource to Loki + request.targets = request.targets.map((target) => { + target.datasource = ds; + return target; + }); + + const targetsSet = new Set(); + request.targets.forEach((target) => { + targetsSet.add(target.resource ?? ''); + }); + + if (targetsSet.size !== 1) { + throw new Error('A request cannot contain queries to multiple endpoints'); + } + + const requestType = request.targets[0].resource; switch (requestType) { case 'volume': { @@ -58,20 +93,11 @@ class WrappedLokiDatasource extends RuntimeDataSource { break; } case 'patterns': { - console.warn('not yet implemented'); - // this.transformPatternResponse(request, ds, subscriber); + this.getPatterns(request, ds, subscriber); break; } default: { - // override the target datasource to Loki - request.targets = request.targets.map((target) => { - target.datasource = ds; - return target; - }); - - // query the datasource and return either observable or promise - const dsResponse = ds.query(request); - dsResponse.subscribe(subscriber); + this.getData(request, ds, subscriber); break; } } @@ -79,6 +105,111 @@ class WrappedLokiDatasource extends RuntimeDataSource { }); } + private getData( + request: SceneDataQueryRequest, + ds: DataSourceWithBackend, + subscriber: Subscriber + ) { + // query the datasource and return either observable or promise + const dsResponse = ds.query(request); + dsResponse.subscribe(subscriber); + + return subscriber; + } + + private getPatterns( + request: DataQueryRequest, + ds: DataSourceWithBackend, + subscriber: Subscriber + ) { + const targets = request.targets.filter((target) => { + return target.resource === 'patterns'; + }); + + if (targets.length !== 1) { + throw new Error('Patterns query can only have a single target!'); + } + + const targetsInterpolated = ds.interpolateVariablesInQueries(targets, request.scopedVars); + const interpolatedTarget = targetsInterpolated[0]; + const expression = interpolatedTarget.expr; + + const dsResponse = ds.getResource( + 'patterns', + { + query: expression, + start: request.range.from.utc().toISOString(), + end: request.range.to.utc().toISOString(), + }, + { + requestId: request.requestId ?? 'patterns', + headers: { + 'X-Query-Tags': `Source=${PLUGIN_ID}`, + }, + } + ); + dsResponse.then((response: PatternsResponse | undefined) => { + const lokiPatterns = response?.data; + + let maxValue = -Infinity; + let minValue = 0; + + const frames = + lokiPatterns?.map((pattern: LokiPattern) => { + const timeValues: number[] = []; + const countValues: number[] = []; + let sum = 0; + pattern.samples.forEach(([time, count]) => { + timeValues.push(time * 1000); + countValues.push(count); + if (count > maxValue) { + maxValue = count; + } + if (count < minValue) { + minValue = count; + } + if (count > maxValue) { + maxValue = count; + } + if (count < minValue) { + minValue = count; + } + sum += count; + }); + return createDataFrame({ + refId: interpolatedTarget.refId, + name: pattern.pattern, + fields: [ + { + name: 'time', + type: FieldType.time, + values: timeValues, + config: {}, + }, + { + name: pattern.pattern, + type: FieldType.number, + values: countValues, + config: {}, + }, + ], + meta: { + preferredVisualisationType: 'graph', + custom: { + sum, + }, + }, + }); + }) ?? []; + + frames.sort((a, b) => (b.meta?.custom?.sum as number) - (a.meta?.custom?.sum as number)); + subscriber.next({ data: frames, state: LoadingState.Done }); + }); + + return subscriber; + } + + //@todo doesn't work with multiple queries private getVolume( request: DataQueryRequest, ds: DataSourceWithBackend, @@ -95,8 +226,8 @@ class WrappedLokiDatasource extends RuntimeDataSource { 'index/volume', { query: expression, - from: request.range.from.utc().toISOString(), - to: request.range.to.utc().toISOString(), + start: request.range.from.utc().toISOString(), + end: request.range.to.utc().toISOString(), limit: 1000, }, { @@ -122,6 +253,8 @@ class WrappedLokiDatasource extends RuntimeDataSource { subscriber.next({ data: [df] }); subscriber.complete(); }); + + return subscriber; } testDatasource(): Promise { diff --git a/src/services/metadata.ts b/src/services/metadata.ts index f7242cc09..8a4339348 100644 --- a/src/services/metadata.ts +++ b/src/services/metadata.ts @@ -17,11 +17,20 @@ export class MetadataService { return this.serviceSceneState; } + public setPatternsCount(count: number) { + if (this.serviceSceneState) { + this.serviceSceneState.patternsCount = count; + } else { + this.serviceSceneState = {}; + this.serviceSceneState.patternsCount = count; + } + } + public setServiceSceneState(state: ServiceSceneCustomState) { this.serviceSceneState = { fields: state.fields, labels: state.labels, - patterns: state.patterns, + patternsCount: state.patternsCount, fieldsCount: state.fieldsCount, loading: state.loading, }; diff --git a/src/services/navigate.test.ts b/src/services/navigate.test.ts index 3c2748c51..fdc896887 100644 --- a/src/services/navigate.test.ts +++ b/src/services/navigate.test.ts @@ -55,15 +55,7 @@ describe('navigate', () => { mockServiceSceneState = { labels, - patterns: [ - { - pattern: 'error <_> message', - samples: [ - [1721220640, '270'], - [1721220650, '341'], - ], - }, - ], + patternsCount: 2, fields: ['field1', 'field2'], fieldsCount: 2, loading: true, diff --git a/src/services/panel.ts b/src/services/panel.ts index ef83932c4..f413842df 100644 --- a/src/services/panel.ts +++ b/src/services/panel.ts @@ -4,6 +4,7 @@ import { map, Observable } from 'rxjs'; import { LokiQuery } from './query'; import { HideSeriesConfig } from '@grafana/schema'; import { WRAPPED_LOKI_DS_UID } from './datasource'; +import { LogsSceneQueryRunner } from './LogsSceneQueryRunner'; const UNKNOWN_LEVEL_LOGS = 'logs'; export function setLeverColorOverrides(overrides: FieldConfigOverridesBuilder) { @@ -78,16 +79,26 @@ export function sortLevelTransformation() { }; } -export function getQueryRunner(query: LokiQuery) { +export function getResourceQueryRunner(queries: LokiQuery[]) { + return new LogsSceneQueryRunner({ + datasource: { uid: WRAPPED_LOKI_DS_UID }, + queries: queries, + }); +} + +export function getQueryRunner(queries: LokiQuery[]) { // if there's a legendFormat related to any `level` like label, we want to // sort the output equally. That's purposefully not `LEVEL_VARIABLE_VALUE`, // such that the `detected_level` graph looks the same as a graph for the // `level` label. - if (query.legendFormat?.toLowerCase().includes('level')) { + + const hasLevel = queries.find((query) => query.legendFormat?.toLowerCase().includes('level')); + + if (hasLevel) { return new SceneDataTransformer({ $data: new SceneQueryRunner({ datasource: { uid: WRAPPED_LOKI_DS_UID }, - queries: [query], + queries: queries, }), transformations: [sortLevelTransformation], }); @@ -95,6 +106,6 @@ export function getQueryRunner(query: LokiQuery) { return new SceneQueryRunner({ datasource: { uid: WRAPPED_LOKI_DS_UID }, - queries: [query], + queries: queries, }); } diff --git a/src/services/query.ts b/src/services/query.ts index 89f39b1cd..e06d792ee 100644 --- a/src/services/query.ts +++ b/src/services/query.ts @@ -30,6 +30,7 @@ export const buildResourceQuery = ( return { ...defaultQueryParams, resource, + refId: resource, ...queryParamsOverrides, datasource: { uid: VAR_DATASOURCE_EXPR }, expr, diff --git a/tests/exploreServicesBreakDown.spec.ts b/tests/exploreServicesBreakDown.spec.ts index ac717b7c3..cdcb87578 100644 --- a/tests/exploreServicesBreakDown.spec.ts +++ b/tests/exploreServicesBreakDown.spec.ts @@ -6,6 +6,7 @@ import {mockEmptyQueryApiResponse} from "./mocks/mockEmptyQueryApiResponse"; import {LokiQuery} from "../src/services/query"; import {DataQueryRequest} from "@grafana/data"; import {DataQuery} from "@grafana/schema"; +import {LOGS_PANEL_QUERY_REFID} from "../src/Components/ServiceScene/ServiceScene"; test.describe('explore services breakdown page', () => { let explorePage: ExplorePage; @@ -113,7 +114,7 @@ test.describe('explore services breakdown page', () => { const postData = JSON.parse(rawPostData); const refId = postData.queries[0].refId // Field subqueries have a refId of the field name - if(refId !== 'A'){ + if(refId !== 'logsPanelQuery' && refId !== 'A'){ requestCount++ return await route.fulfill({json: mockResponse}) }