Skip to content

Commit

Permalink
Merge pull request #473 from grafana/matyax/service-scene-filtering
Browse files Browse the repository at this point in the history
Service selection scene: filter logs by the selected level in the chart
  • Loading branch information
matyax authored Jun 21, 2024
2 parents ed06631 + f4197dc commit 1b9ba5b
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 47 deletions.
4 changes: 2 additions & 2 deletions src/Components/IndexScene/IndexScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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[]) {
Expand Down
14 changes: 2 additions & 12 deletions src/Components/ServiceScene/Breakdowns/BreakdownSearchScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,16 +71,5 @@ export class BreakdownSearchScene extends SceneObjectBase<BreakdownSearchSceneSt
}

export function getLabelValue(frame: DataFrame) {
const labels = frame.fields[1]?.labels;

if (!labels) {
return 'No labels';
}

const keys = Object.keys(labels);
if (keys.length === 0) {
return 'No labels';
}

return labels[keys[0]];
return getLabelValueFromDataFrame(frame) ?? 'No labels';
}
133 changes: 100 additions & 33 deletions src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SceneObjectState,
SceneVariable,
VariableDependencyConfig,
VizPanel,
} from '@grafana/scenes';
import {
DrawStyle,
Expand All @@ -22,6 +23,8 @@ import {
Input,
LegendDisplayMode,
LoadingPlaceholder,
PanelContext,
SeriesVisibilityChangeMode,
StackingMode,
useStyles2,
} from '@grafana/ui';
Expand All @@ -37,10 +40,11 @@ import { getQueryRunner, setLeverColorOverrides } from 'services/panel';
import { ConfigureVolumeError } from './ConfigureVolumeError';
import { NoVolumeError } from './NoVolumeError';
import { PluginPage } from '@grafana/runtime';
import { getLabelsFromSeries, toggleLevelFromFilter } from 'services/levels';

export const SERVICE_NAME = 'service_name';

interface ServiceSelectionComponentState extends SceneObjectState {
interface ServiceSelectionSceneState extends SceneObjectState {
// The body of the component
body: SceneCSSGridLayout;
// We query volume endpoint to get list of all services and order them by volume
Expand All @@ -53,13 +57,15 @@ interface ServiceSelectionComponentState extends SceneObjectState {
servicesToQuery?: string[];
// in case the volume api errors out
volumeApiError?: boolean;
// Show logs of a certain level for a given service
serviceLevel: Map<string, string[]>;
}

export class StartingPointSelectedEvent extends BusEventBase {
public static type = 'start-point-selected-event';
}

export class ServiceSelectionComponent extends SceneObjectBase<ServiceSelectionComponentState> {
export class ServiceSelectionScene extends SceneObjectBase<ServiceSelectionSceneState> {
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],
Expand All @@ -72,13 +78,14 @@ export class ServiceSelectionComponent extends SceneObjectBase<ServiceSelectionC
},
});

constructor(state: Partial<ServiceSelectionComponentState>) {
constructor(state: Partial<ServiceSelectionSceneState>) {
super({
body: new SceneCSSGridLayout({ children: [] }),
isServicesByVolumeLoading: false,
servicesByVolume: undefined,
searchServicesString: '',
servicesToQuery: undefined,
serviceLevel: new Map<string, string[]>(),
...state,
});

Expand Down Expand Up @@ -216,60 +223,120 @@ export class ServiceSelectionComponent extends SceneObjectBase<ServiceSelectionC
}
}

/**
* Redraws service logs after toggling level visibility.
*/
private updateServiceLogs(service: string) {
if (!this.state.body) {
this.updateBody();
return;
}
const serviceIndex = this.state.servicesToQuery?.indexOf(service);
if (serviceIndex === undefined || serviceIndex < 0) {
return;
}
this.state.body.forEachChild((scene) => {
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()
// Hover header set to true removes unused header padding, displaying more logs
.setHoverHeader(true)
.setData(
getQueryRunner(
buildLokiQuery(`{${SERVICE_NAME}=\`${service}\`}`, { maxLines: 100, refId: `logs-${service}` })
buildLokiQuery(`{${SERVICE_NAME}=\`${service}\`}${levelFilter}`, {
maxLines: 100,
refId: `logs-${service}`,
})
)
)
.setOption('showTime', true)
.setOption('enableLogDetails', false)
.build(),
});
}
};

// We could also run model.setState in component, but it is recommended to implement the state-modifying methods in the scene object
public onSearchServicesChange = debounce((serviceString: string) => {
Expand All @@ -285,7 +352,7 @@ export class ServiceSelectionComponent extends SceneObjectBase<ServiceSelectionC
);
}, 500);

public static Component = ({ model }: SceneComponentProps<ServiceSelectionComponent>) => {
public static Component = ({ model }: SceneComponentProps<ServiceSelectionScene>) => {
const styles = useStyles2(getStyles);
const { isServicesByVolumeLoading, servicesByVolume, servicesToQuery, body, volumeApiError } = model.useState();

Expand Down
94 changes: 94 additions & 0 deletions src/services/levels.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading

0 comments on commit 1b9ba5b

Please sign in to comment.