From dfd9813d70547ac3db7181ce2fa5a9cf67e41304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 15 Nov 2021 15:13:01 +0100 Subject: [PATCH] Suggestions: Adds logs suggestions and sorting by score to suggestions (#41613) * Suggestions: Adds logs suggestions for sorting by score to suggestions * Introduced an enum * review feedback --- packages/grafana-data/src/types/panel.ts | 26 ++- .../panel/state/getAllSuggestions.test.ts | 30 +++- .../features/panel/state/getAllSuggestions.ts | 13 +- public/app/plugins/panel/logs/module.tsx | 157 +++++++++--------- public/app/plugins/panel/logs/suggestions.ts | 33 ++++ .../app/plugins/panel/piechart/suggestions.ts | 1 - public/app/plugins/panel/stat/suggestions.ts | 30 +++- .../panel/state-timeline/suggestions.ts | 13 +- .../panel/status-history/suggestions.ts | 15 +- public/app/types/suggestions.ts | 1 + 10 files changed, 221 insertions(+), 98 deletions(-) create mode 100644 public/app/plugins/panel/logs/suggestions.ts diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index a0f10643d44..71cf1cc4637 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -1,7 +1,7 @@ import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource'; import { PluginMeta } from './plugin'; import { ScopedVars } from './ScopedVars'; -import { LoadingState } from './data'; +import { LoadingState, PreferredVisualisationType } from './data'; import { DataFrame, FieldType } from './dataFrame'; import { AbsoluteTimeRange, TimeRange, TimeZone } from './time'; import { EventBus } from '../events'; @@ -203,6 +203,17 @@ export interface VisualizationSuggestion { transformations?: DataTransformerConfig[]; /** Tweak for small preview */ previewModifier?: (suggestion: VisualizationSuggestion) => void; + /** A value between 0-100 how suitable suggestion is */ + score?: VisualizationSuggestionScore; +} + +export enum VisualizationSuggestionScore { + /** We are pretty sure this is the best possible option */ + Best = 100, + /** Should be a really good option */ + Good = 70, + /** Can be visualized but there are likely better options. If no score is set this score is assumed */ + OK = 50, } /** @@ -213,12 +224,15 @@ export interface PanelDataSummary { rowCountTotal: number; rowCountMax: number; frameCount: number; + fieldCount: number; numberFieldCount: number; timeFieldCount: number; stringFieldCount: number; hasNumberField?: boolean; hasTimeField?: boolean; hasStringField?: boolean; + /** The first frame that set's this value */ + preferredVisualisationType?: PreferredVisualisationType; } /** @@ -252,11 +266,19 @@ export class VisualizationSuggestionsBuilder { let stringFieldCount = 0; let rowCountTotal = 0; let rowCountMax = 0; + let fieldCount = 0; + let preferredVisualisationType: PreferredVisualisationType | undefined; for (const frame of frames) { rowCountTotal += frame.length; + if (frame.meta?.preferredVisualisationType) { + preferredVisualisationType = frame.meta.preferredVisualisationType; + } + for (const field of frame.fields) { + fieldCount++; + switch (field.type) { case FieldType.number: numberFieldCount += 1; @@ -281,6 +303,8 @@ export class VisualizationSuggestionsBuilder { stringFieldCount, rowCountTotal, rowCountMax, + fieldCount, + preferredVisualisationType, frameCount: frames.length, hasData: rowCountTotal > 0, hasTimeField: timeFieldCount > 0, diff --git a/public/app/features/panel/state/getAllSuggestions.test.ts b/public/app/features/panel/state/getAllSuggestions.test.ts index 81c90cb68d4..32ad62a317b 100644 --- a/public/app/features/panel/state/getAllSuggestions.test.ts +++ b/public/app/features/panel/state/getAllSuggestions.test.ts @@ -280,7 +280,7 @@ scenario('Single frame with string and 2 number field', (ctx) => { }); }); -scenario('Single frame with string with only string field', (ctx) => { +scenario('Single frame with only string field', (ctx) => { ctx.setData([ toDataFrame({ fields: [{ name: 'Name', type: FieldType.string, values: ['Hugo', 'Dominik', 'Marcus'] }], @@ -288,7 +288,7 @@ scenario('Single frame with string with only string field', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.StatColoredBackground, SuggestionName.Table]); + expect(ctx.names()).toEqual([SuggestionName.Stat, SuggestionName.Table]); }); it('Stat panels have reduceOptions.fields set to show all fields', () => { @@ -300,6 +300,32 @@ scenario('Single frame with string with only string field', (ctx) => { }); }); +scenario('Given default loki logs data', (ctx) => { + ctx.setData([ + toDataFrame({ + fields: [ + { name: 'ts', type: FieldType.time, values: ['2021-11-11T13:38:45.440Z', '2021-11-11T13:38:45.190Z'] }, + { + name: 'line', + type: FieldType.string, + values: [ + 't=2021-11-11T14:38:45+0100 lvl=dbug msg="Client connected" logger=live user=1 client=ee79155b-a8d1-4730-bcb3-94d8690df35c', + 't=2021-11-11T14:38:45+0100 lvl=dbug msg="Adding CSP header to response" logger=http.server cfg=0xc0005fed00', + ], + labels: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + }, + ], + meta: { + preferredVisualisationType: 'logs', + }, + }), + ]); + + it('should return correct suggestions', () => { + expect(ctx.names()).toEqual([SuggestionName.Logs, SuggestionName.Table]); + }); +}); + function repeatFrame(count: number, frame: DataFrame): DataFrame[] { const frames: DataFrame[] = []; for (let i = 0; i < count; i++) { diff --git a/public/app/features/panel/state/getAllSuggestions.ts b/public/app/features/panel/state/getAllSuggestions.ts index f174533e1f6..27b68bee3ba 100644 --- a/public/app/features/panel/state/getAllSuggestions.ts +++ b/public/app/features/panel/state/getAllSuggestions.ts @@ -1,4 +1,10 @@ -import { PanelData, VisualizationSuggestion, VisualizationSuggestionsBuilder, PanelModel } from '@grafana/data'; +import { + PanelData, + VisualizationSuggestion, + VisualizationSuggestionsBuilder, + PanelModel, + VisualizationSuggestionScore, +} from '@grafana/data'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; export const panelsToCheckFirst = [ @@ -13,6 +19,7 @@ export const panelsToCheckFirst = [ 'status-history', 'text', 'dashlist', + 'logs', ]; export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise { @@ -27,5 +34,7 @@ export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): P } } - return builder.getList(); + return builder.getList().sort((a, b) => { + return (b.score ?? VisualizationSuggestionScore.OK) - (a.score ?? VisualizationSuggestionScore.OK); + }); } diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index 158ac5ffcf7..d27bf0dc627 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -1,81 +1,84 @@ import { PanelPlugin, LogsSortOrder, LogsDedupStrategy, LogsDedupDescription } from '@grafana/data'; import { Options } from './types'; import { LogsPanel } from './LogsPanel'; +import { LogsPanelSuggestionsSupplier } from './suggestions'; -export const plugin = new PanelPlugin(LogsPanel).setPanelOptions((builder) => { - builder - .addBooleanSwitch({ - path: 'showTime', - name: 'Time', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'showLabels', - name: 'Unique labels', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'showCommonLabels', - name: 'Common labels', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'wrapLogMessage', - name: 'Wrap lines', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'prettifyLogMessage', - name: 'Prettify JSON', - description: '', - defaultValue: false, - }) - .addBooleanSwitch({ - path: 'enableLogDetails', - name: 'Enable log details', - description: '', - defaultValue: true, - }) - .addRadio({ - path: 'dedupStrategy', - name: 'Deduplication', - description: '', - settings: { - options: [ - { value: LogsDedupStrategy.none, label: 'None', description: LogsDedupDescription[LogsDedupStrategy.none] }, - { - value: LogsDedupStrategy.exact, - label: 'Exact', - description: LogsDedupDescription[LogsDedupStrategy.exact], - }, - { - value: LogsDedupStrategy.numbers, - label: 'Numbers', - description: LogsDedupDescription[LogsDedupStrategy.numbers], - }, - { - value: LogsDedupStrategy.signature, - label: 'Signature', - description: LogsDedupDescription[LogsDedupStrategy.signature], - }, - ], - }, - defaultValue: LogsDedupStrategy.none, - }) - .addRadio({ - path: 'sortOrder', - name: 'Order', - description: '', - settings: { - options: [ - { value: LogsSortOrder.Descending, label: 'Descending' }, - { value: LogsSortOrder.Ascending, label: 'Ascending' }, - ], - }, - defaultValue: LogsSortOrder.Descending, - }); -}); +export const plugin = new PanelPlugin(LogsPanel) + .setPanelOptions((builder) => { + builder + .addBooleanSwitch({ + path: 'showTime', + name: 'Time', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'showLabels', + name: 'Unique labels', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'showCommonLabels', + name: 'Common labels', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'wrapLogMessage', + name: 'Wrap lines', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'prettifyLogMessage', + name: 'Prettify JSON', + description: '', + defaultValue: false, + }) + .addBooleanSwitch({ + path: 'enableLogDetails', + name: 'Enable log details', + description: '', + defaultValue: true, + }) + .addRadio({ + path: 'dedupStrategy', + name: 'Deduplication', + description: '', + settings: { + options: [ + { value: LogsDedupStrategy.none, label: 'None', description: LogsDedupDescription[LogsDedupStrategy.none] }, + { + value: LogsDedupStrategy.exact, + label: 'Exact', + description: LogsDedupDescription[LogsDedupStrategy.exact], + }, + { + value: LogsDedupStrategy.numbers, + label: 'Numbers', + description: LogsDedupDescription[LogsDedupStrategy.numbers], + }, + { + value: LogsDedupStrategy.signature, + label: 'Signature', + description: LogsDedupDescription[LogsDedupStrategy.signature], + }, + ], + }, + defaultValue: LogsDedupStrategy.none, + }) + .addRadio({ + path: 'sortOrder', + name: 'Order', + description: '', + settings: { + options: [ + { value: LogsSortOrder.Descending, label: 'Descending' }, + { value: LogsSortOrder.Ascending, label: 'Ascending' }, + ], + }, + defaultValue: LogsSortOrder.Descending, + }); + }) + .setSuggestionsSupplier(new LogsPanelSuggestionsSupplier()); diff --git a/public/app/plugins/panel/logs/suggestions.ts b/public/app/plugins/panel/logs/suggestions.ts new file mode 100644 index 00000000000..a87169b058c --- /dev/null +++ b/public/app/plugins/panel/logs/suggestions.ts @@ -0,0 +1,33 @@ +import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data'; +import { SuggestionName } from 'app/types/suggestions'; +import { Options } from './types'; + +export class LogsPanelSuggestionsSupplier { + getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { + const list = builder.getListAppender({ + name: '', + pluginId: 'logs', + options: {}, + fieldConfig: { + defaults: { + custom: {}, + }, + overrides: [], + }, + previewModifier: () => {}, + }); + + const { dataSummary: ds } = builder; + + // Require a string & time field + if (!ds.hasData || !ds.hasTimeField || !ds.hasStringField) { + return; + } + + if (ds.preferredVisualisationType === 'logs') { + list.append({ name: SuggestionName.Logs, score: VisualizationSuggestionScore.Best }); + } else { + list.append({ name: SuggestionName.Logs }); + } + } +} diff --git a/public/app/plugins/panel/piechart/suggestions.ts b/public/app/plugins/panel/piechart/suggestions.ts index 838a8c39c38..f6ded5ddcd7 100644 --- a/public/app/plugins/panel/piechart/suggestions.ts +++ b/public/app/plugins/panel/piechart/suggestions.ts @@ -22,7 +22,6 @@ export class PieChartSuggestionsSupplier { previewModifier: (s) => { // Hide labels in preview s.options!.legend.displayMode = LegendDisplayMode.Hidden; - s.options!.displayLabels = []; }, }); diff --git a/public/app/plugins/panel/stat/suggestions.ts b/public/app/plugins/panel/stat/suggestions.ts index e9609c49435..08109e68a4e 100644 --- a/public/app/plugins/panel/stat/suggestions.ts +++ b/public/app/plugins/panel/stat/suggestions.ts @@ -5,9 +5,9 @@ import { StatPanelOptions } from './types'; export class StatSuggestionsSupplier { getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; + const { dataSummary: ds } = builder; - if (!dataSummary.hasData) { + if (!ds.hasData) { return; } @@ -29,14 +29,15 @@ export class StatSuggestionsSupplier { }, }); - if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { + // String and number field with low row count show individual rows + if (ds.hasStringField && ds.hasNumberField && ds.frameCount === 1 && ds.rowCountTotal < 10) { list.append({ name: SuggestionName.Stat, options: { reduceOptions: { values: true, calcs: [], - fields: dataSummary.hasNumberField ? undefined : '/.*/', + fields: '/.*/', }, }, }); @@ -46,12 +47,29 @@ export class StatSuggestionsSupplier { reduceOptions: { values: true, calcs: [], - fields: dataSummary.hasNumberField ? undefined : '/.*/', + fields: '/.*/', }, colorMode: BigValueColorMode.Background, }, }); - } else if (dataSummary.hasNumberField) { + } + + // Just a single string field + if (ds.stringFieldCount === 1 && ds.frameCount === 1 && ds.rowCountTotal < 10 && ds.fieldCount === 1) { + list.append({ + name: SuggestionName.Stat, + options: { + reduceOptions: { + values: true, + calcs: [], + fields: '/.*/', + }, + colorMode: BigValueColorMode.None, + }, + }); + } + + if (ds.hasNumberField && ds.hasTimeField) { list.append({ options: { reduceOptions: { diff --git a/public/app/plugins/panel/state-timeline/suggestions.ts b/public/app/plugins/panel/state-timeline/suggestions.ts index 2eb4219ba44..0fe8774b4b9 100644 --- a/public/app/plugins/panel/state-timeline/suggestions.ts +++ b/public/app/plugins/panel/state-timeline/suggestions.ts @@ -4,19 +4,24 @@ import { TimelineFieldConfig, TimelineOptions } from './types'; export class StatTimelineSuggestionsSupplier { getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; + const { dataSummary: ds } = builder; - if (!dataSummary.hasData) { + if (!ds.hasData) { return; } // This panel needs a time field and a string or number field - if (!dataSummary.hasTimeField || (!dataSummary.hasStringField && !dataSummary.hasNumberField)) { + if (!ds.hasTimeField || (!ds.hasStringField && !ds.hasNumberField)) { return; } // If there are many series then they won't fit on y-axis so this panel is not good fit - if (dataSummary.numberFieldCount >= 30) { + if (ds.numberFieldCount >= 30) { + return; + } + + // Probably better ways to filter out this by inspecting the types of string values so view this as temporary + if (ds.preferredVisualisationType === 'logs') { return; } diff --git a/public/app/plugins/panel/status-history/suggestions.ts b/public/app/plugins/panel/status-history/suggestions.ts index f2b12ba04c9..ed4c07ae6d1 100644 --- a/public/app/plugins/panel/status-history/suggestions.ts +++ b/public/app/plugins/panel/status-history/suggestions.ts @@ -4,24 +4,29 @@ import { StatusPanelOptions, StatusFieldConfig } from './types'; export class StatusHistorySuggestionsSupplier { getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; + const { dataSummary: ds } = builder; - if (!dataSummary.hasData) { + if (!ds.hasData) { return; } // This panel needs a time field and a string or number field - if (!dataSummary.hasTimeField || (!dataSummary.hasStringField && !dataSummary.hasNumberField)) { + if (!ds.hasTimeField || (!ds.hasStringField && !ds.hasNumberField)) { return; } // If there are many series then they won't fit on y-axis so this panel is not good fit - if (dataSummary.numberFieldCount >= 30) { + if (ds.numberFieldCount >= 30) { return; } // if there a lot of data points for each series then this is not a good match - if (dataSummary.rowCountMax > 100) { + if (ds.rowCountMax > 100) { + return; + } + + // Probably better ways to filter out this by inspecting the types of string values so view this as temporary + if (ds.preferredVisualisationType === 'logs') { return; } diff --git a/public/app/types/suggestions.ts b/public/app/types/suggestions.ts index 11195b712d9..0a4576ba671 100644 --- a/public/app/types/suggestions.ts +++ b/public/app/types/suggestions.ts @@ -25,4 +25,5 @@ export enum SuggestionName { StatusHistory = 'Status history', TextPanel = 'Text panel', DashboardList = 'Dashboard list', + Logs = 'Logs', }