diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index 3188c5cb5..131418f8f 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -32,7 +32,7 @@ import { import { addLastUsedDataSourceToStorage, getLastUsedDataSourceFromStorage } from 'services/store'; import { ServiceScene } from '../ServiceScene/ServiceScene'; -import { ServiceSelectionComponent, StartingPointSelectedEvent } from '../ServiceSelectionScene/ServiceSelectionScene'; +import { ServiceSelectionScene, StartingPointSelectedEvent } from '../ServiceSelectionScene/ServiceSelectionScene'; import { LayoutScene } from './LayoutScene'; import { FilterOp } from 'services/filters'; @@ -141,7 +141,7 @@ function getContentScene(mode?: LogExplorationMode) { if (mode === 'service_details') { return new ServiceScene({}); } - return new ServiceSelectionComponent({}); + return new ServiceSelectionScene({}); } function getVariableSet(initialDS?: string, initialFilters?: AdHocVariableFilter[]) { diff --git a/src/Components/ServiceScene/Breakdowns/BreakdownSearchScene.tsx b/src/Components/ServiceScene/Breakdowns/BreakdownSearchScene.tsx index 0296cf778..2edde314d 100644 --- a/src/Components/ServiceScene/Breakdowns/BreakdownSearchScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/BreakdownSearchScene.tsx @@ -6,6 +6,7 @@ import { SearchInput } from './SearchInput'; import { LabelBreakdownScene } from './LabelBreakdownScene'; import { FieldsBreakdownScene } from './FieldsBreakdownScene'; import { fuzzySearch } from '../../../services/search'; +import { getLabelValueFromDataFrame } from 'services/levels'; export interface BreakdownSearchSceneState extends SceneObjectState { filter?: string; @@ -70,16 +71,5 @@ export class BreakdownSearchScene extends SceneObjectBase; } export class StartingPointSelectedEvent extends BusEventBase { public static type = 'start-point-selected-event'; } -export class ServiceSelectionComponent extends SceneObjectBase { +export class ServiceSelectionScene extends SceneObjectBase { protected _variableDependency = new VariableDependencyConfig(this, { // We want to subscribe to changes in datasource variables and update the top services when the datasource changes variableNames: [VAR_DATASOURCE], @@ -72,13 +78,14 @@ export class ServiceSelectionComponent extends SceneObjectBase) { + constructor(state: Partial) { super({ body: new SceneCSSGridLayout({ children: [] }), isServicesByVolumeLoading: false, servicesByVolume: undefined, searchServicesString: '', servicesToQuery: undefined, + serviceLevel: new Map(), ...state, }); @@ -216,45 +223,102 @@ export class ServiceSelectionComponent extends SceneObjectBase { + if (scene instanceof SceneCSSGridLayout) { + let newChildren = [...scene.state.children]; + newChildren.splice(serviceIndex * 2 + 1, 1, this.buildServiceLogsLayout(service)); + scene.setState({ children: newChildren }); + } + }); + } + + private extendTimeSeriesLegendBus = (service: string, context: PanelContext, panel: VizPanel) => { + const originalOnToggleSeriesVisibility = context.onToggleSeriesVisibility; + + context.onToggleSeriesVisibility = (level: string, mode: SeriesVisibilityChangeMode) => { + originalOnToggleSeriesVisibility?.(level, mode); + + const allLevels = getLabelsFromSeries(panel.state.$data?.state.data?.series ?? []); + + const levels = toggleLevelFromFilter(level, this.state.serviceLevel.get(service), mode, allLevels); + this.state.serviceLevel.set(service, levels); + + this.updateServiceLogs(service); + }; + }; + // Creates a layout with timeseries panel buildServiceLayout(service: string, timeRange: TimeRange) { let splitDuration; if (timeRange.to.diff(timeRange.from, 'hours') >= 4 && timeRange.to.diff(timeRange.from, 'hours') <= 26) { splitDuration = '2h'; } - return new SceneCSSGridItem({ - $behaviors: [new behaviors.CursorSync({ key: 'serviceCrosshairSync', sync: DashboardCursorSync.Crosshair })], - body: PanelBuilders.timeseries() - // If service was previously selected, we show it in the title - .setTitle(service) - .setData( - getQueryRunner( - buildLokiQuery( - `sum by (${LEVEL_VARIABLE_VALUE}) (count_over_time({${SERVICE_NAME}=\`${service}\`} | drop __error__ [$__auto]))`, - { legendFormat: `{{${LEVEL_VARIABLE_VALUE}}}`, splitDuration, refId: `ts-${service}` } - ) + const panel = PanelBuilders.timeseries() + // If service was previously selected, we show it in the title + .setTitle(service) + .setData( + getQueryRunner( + buildLokiQuery( + `sum by (${LEVEL_VARIABLE_VALUE}) (count_over_time({${SERVICE_NAME}=\`${service}\`} | drop __error__ [$__auto]))`, + { legendFormat: `{{${LEVEL_VARIABLE_VALUE}}}`, splitDuration, refId: `ts-${service}` } ) ) - .setCustomFieldConfig('stacking', { mode: StackingMode.Normal }) - .setCustomFieldConfig('fillOpacity', 100) - .setCustomFieldConfig('lineWidth', 0) - .setCustomFieldConfig('pointSize', 0) - .setCustomFieldConfig('drawStyle', DrawStyle.Bars) - .setUnit('short') - .setOverrides(setLeverColorOverrides) - .setOption('legend', { - showLegend: true, - calcs: ['sum'], - placement: 'right', - displayMode: LegendDisplayMode.Table, - }) - .setHeaderActions(new SelectServiceButton({ service })) - .build(), + ) + .setCustomFieldConfig('stacking', { mode: StackingMode.Normal }) + .setCustomFieldConfig('fillOpacity', 100) + .setCustomFieldConfig('lineWidth', 0) + .setCustomFieldConfig('pointSize', 0) + .setCustomFieldConfig('drawStyle', DrawStyle.Bars) + .setUnit('short') + .setOverrides(setLeverColorOverrides) + .setOption('legend', { + showLegend: true, + calcs: ['sum'], + placement: 'right', + displayMode: LegendDisplayMode.Table, + }) + .setHeaderActions(new SelectServiceButton({ service })) + .build(); + + panel.setState({ + extendPanelContext: (_, context) => this.extendTimeSeriesLegendBus(service, context, panel), + }); + + return new SceneCSSGridItem({ + $behaviors: [new behaviors.CursorSync({ key: 'serviceCrosshairSync', sync: DashboardCursorSync.Crosshair })], + body: panel, }); } + getLevelFilterForService = (service: string) => { + let serviceLevels = this.state.serviceLevel.get(service) || []; + if (serviceLevels.length === 0) { + return ''; + } + const filters = serviceLevels.map((level) => { + if (level === 'logs') { + level = ''; + } + return `detected_level=\`${level}\``; + }); + return ` | ${filters.join(' or ')} `; + }; + // Creates a layout with logs panel - buildServiceLogsLayout(service: string) { + buildServiceLogsLayout = (service: string) => { + const levelFilter = this.getLevelFilterForService(service); return new SceneCSSGridItem({ $behaviors: [new behaviors.CursorSync({ sync: DashboardCursorSync.Off })], body: PanelBuilders.logs() @@ -262,14 +326,17 @@ export class ServiceSelectionComponent extends SceneObjectBase { @@ -285,7 +352,7 @@ export class ServiceSelectionComponent extends SceneObjectBase) => { + public static Component = ({ model }: SceneComponentProps) => { const styles = useStyles2(getStyles); const { isServicesByVolumeLoading, servicesByVolume, servicesToQuery, body, volumeApiError } = model.useState(); diff --git a/src/services/levels.test.ts b/src/services/levels.test.ts new file mode 100644 index 000000000..7eb0916e9 --- /dev/null +++ b/src/services/levels.test.ts @@ -0,0 +1,94 @@ +import { SeriesVisibilityChangeMode } from '@grafana/ui'; +import { getLabelsFromSeries, toggleLevelFromFilter } from './levels'; +import { FieldType, toDataFrame } from '@grafana/data'; + +const ALL_LEVELS = ['logs', 'debug', 'info', 'warn', 'error', 'crit']; + +describe('toggleLevelFromFilter', () => { + describe('Visibility mode toggle selection', () => { + it('adds the level', () => { + expect(toggleLevelFromFilter('error', [], SeriesVisibilityChangeMode.ToggleSelection, ALL_LEVELS)).toEqual([ + 'error', + ]); + expect(toggleLevelFromFilter('error', undefined, SeriesVisibilityChangeMode.ToggleSelection, ALL_LEVELS)).toEqual( + ['error'] + ); + }); + it('adds the level if the filter was not empty', () => { + expect( + toggleLevelFromFilter('error', ['info', 'debug'], SeriesVisibilityChangeMode.ToggleSelection, ALL_LEVELS) + ).toEqual(['error']); + }); + it('removes the level if the filter contained only the same level', () => { + expect(toggleLevelFromFilter('error', ['error'], SeriesVisibilityChangeMode.ToggleSelection, ALL_LEVELS)).toEqual( + [] + ); + }); + }); + describe('Visibility mode append to selection', () => { + it('appends the label to other levels', () => { + expect( + toggleLevelFromFilter('error', ['info'], SeriesVisibilityChangeMode.AppendToSelection, ALL_LEVELS) + ).toEqual(['info', 'error']); + }); + it('removes the label if already present', () => { + expect( + toggleLevelFromFilter('error', ['info', 'error'], SeriesVisibilityChangeMode.AppendToSelection, ALL_LEVELS) + ).toEqual(['info']); + }); + it('appends all levels except the provided level if the filter was previously empty', () => { + const allButError = ALL_LEVELS.filter((level) => level !== 'error'); + expect(toggleLevelFromFilter('error', [], SeriesVisibilityChangeMode.AppendToSelection, ALL_LEVELS)).toEqual( + allButError + ); + expect( + toggleLevelFromFilter('error', undefined, SeriesVisibilityChangeMode.AppendToSelection, ALL_LEVELS) + ).toEqual(allButError); + }); + }); +}); + +describe('getLabelsFromSeries', () => { + const series = [ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [0] }, + { + name: 'Value', + type: FieldType.number, + values: [1], + labels: { + detected_level: 'error', + }, + }, + ], + }), + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [0] }, + { + name: 'Value', + type: FieldType.number, + values: [1], + labels: { + detected_level: 'warn', + }, + }, + ], + }), + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [0] }, + { + name: 'Value', + type: FieldType.number, + values: [1], + labels: {}, + }, + ], + }), + ]; + it('returns the label value from time series', () => { + expect(getLabelsFromSeries(series)).toEqual(['error', 'warn', 'logs']); + }); +}); diff --git a/src/services/levels.ts b/src/services/levels.ts new file mode 100644 index 000000000..7e2452571 --- /dev/null +++ b/src/services/levels.ts @@ -0,0 +1,46 @@ +import { DataFrame } from '@grafana/data'; +import { SeriesVisibilityChangeMode } from '@grafana/ui'; + +export function toggleLevelFromFilter( + level: string, + serviceLevels: string[] | undefined, + mode: SeriesVisibilityChangeMode, + allLevels: string[] +) { + if (mode === SeriesVisibilityChangeMode.ToggleSelection) { + const levels = serviceLevels ?? []; + if (levels.length === 1 && levels.includes(level)) { + return levels.filter((existingLevel) => existingLevel !== level); + } + return [level]; + } + /** + * When the behavior is `AppendToSelection` and the filter is empty, we initialize it + * with all levels because the user is excluding this level in their action. + */ + let levels = !serviceLevels?.length ? allLevels : serviceLevels; + if (levels.includes(level)) { + return levels.filter((existingLevel) => existingLevel !== level); + } + + return [...levels, level]; +} + +export function getLabelsFromSeries(series: DataFrame[]) { + return series.map((dataFrame) => getLabelValueFromDataFrame(dataFrame) ?? 'logs'); +} + +export function getLabelValueFromDataFrame(frame: DataFrame) { + const labels = frame.fields[1]?.labels; + + if (!labels) { + return null; + } + + const keys = Object.keys(labels); + if (keys.length === 0) { + return null; + } + + return labels[keys[0]]; +}