diff --git a/devenv/dev-dashboards/panel-table/table_tests_new.json b/devenv/dev-dashboards/panel-table/table_tests_new.json index 31e7d074e25..11c6c8c0eb8 100644 --- a/devenv/dev-dashboards/panel-table/table_tests_new.json +++ b/devenv/dev-dashboards/panel-table/table_tests_new.json @@ -132,7 +132,7 @@ } ] }, - "pluginVersion": "7.5.0-pre", + "pluginVersion": "", "targets": [ { "refId": "A", @@ -263,7 +263,7 @@ } ] }, - "pluginVersion": "7.5.0-pre", + "pluginVersion": "", "targets": [ { "refId": "A", @@ -351,7 +351,7 @@ "showHeader": true, "sortBy": [] }, - "pluginVersion": "7.5.0-pre", + "pluginVersion": "", "targets": [ { "refId": "A", @@ -387,7 +387,7 @@ }, { "collapsed": false, - "datasource": null, + "datasource": "gdev-testdata", "gridPos": { "h": 1, "w": 24, @@ -460,7 +460,7 @@ "value": [ { "title": "Details", - "url": "http://detail?serverLabel=${__field.labels.server}&valueNumeric=${__value.numeric}" + "url": "http://detail?serverLabel=${__field.labels.server}&valueNumeric=${__value.numeric}" } ] } @@ -479,7 +479,7 @@ "options": { "showHeader": true }, - "pluginVersion": "7.5.0-pre", + "pluginVersion": "", "targets": [ { "alias": "S1", @@ -571,7 +571,7 @@ } ] }, - "pluginVersion": "7.5.0-pre", + "pluginVersion": "", "targets": [ { "refId": "A", @@ -604,6 +604,55 @@ } ], "type": "table" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 12, + "options": { + "footer": { + "show": true, + "fields": "", + "reducer": [ + "sum" + ] + }, + "showHeader": true + }, + "pluginVersion": "", + "title": "Footer", + "type": "table" } ], "schemaVersion": 27, @@ -635,5 +684,5 @@ "timezone": "", "title": "Panel Tests - React Table", "uid": "U_bZIMRMk", - "version": 1 + "version": 6 } \ No newline at end of file diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index bd50f95fdba..69326b3baef 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -1,5 +1,4 @@ import React, { Component } from 'react'; - import { Select, Table } from '@grafana/ui'; import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableValue } from '@grafana/data'; import { PanelOptions } from './models.gen'; @@ -9,6 +8,7 @@ import { FilterItem, TableSortByFieldState } from '@grafana/ui/src/components/Ta import { dispatch } from '../../../store/store'; import { applyFilterFromTable } from '../../../features/variables/adhoc/actions'; import { getDashboardSrv } from '../../../features/dashboard/services/DashboardSrv'; +import { getFooterCells } from './footer'; interface Props extends PanelProps {} @@ -79,6 +79,7 @@ export class TablePanel extends Component { renderTable(frame: DataFrame, width: number, height: number) { const { options } = this.props; + const footerValues = options.footer?.show ? getFooterCells(frame, options.footer) : undefined; return ( { onSortByChange={this.onSortByChange} onColumnResize={this.onColumnResize} onCellFilterAdded={this.onCellFilterAdded} + footerValues={footerValues} /> ); } - getCurrentFrameIndex() { - const { data, options } = this.props; - const count = data.series?.length; - return options.frameIndex > 0 && options.frameIndex < count ? options.frameIndex : 0; + getCurrentFrameIndex(frames: DataFrame[], options: PanelOptions) { + return options.frameIndex > 0 && options.frameIndex < frames.length ? options.frameIndex : 0; } render() { - const { data, height, width } = this.props; + const { data, height, width, options } = this.props; - const count = data.series?.length; - const hasFields = data.series[0]?.fields.length; + const frames = data.series; + const count = frames?.length; + const hasFields = frames[0]?.fields.length; if (!count || !hasFields) { return
No data
; @@ -115,8 +116,8 @@ export class TablePanel extends Component { if (count > 1) { const inputHeight = config.theme.spacing.formInputHeight; const padding = 8 * 2; - const currentIndex = this.getCurrentFrameIndex(); - const names = data.series.map((frame, index) => { + const currentIndex = this.getCurrentFrameIndex(frames, options); + const names = frames.map((frame, index) => { return { label: getFrameDisplayName(frame), value: index, diff --git a/public/app/plugins/panel/table/footer.ts b/public/app/plugins/panel/table/footer.ts new file mode 100644 index 00000000000..3611cbbd371 --- /dev/null +++ b/public/app/plugins/panel/table/footer.ts @@ -0,0 +1,40 @@ +import { + DataFrame, + Field, + FieldType, + formattedValueToString, + getDisplayProcessor, + reduceField, + fieldReducers, +} from '@grafana/data'; +import { FooterItem } from '@grafana/ui/src/components/Table/types'; +import { TableFooterCalc } from './models.gen'; +import { config } from 'app/core/config'; + +export function getFooterCells(frame: DataFrame, options?: TableFooterCalc): FooterItem[] { + return frame.fields.map((field, i) => { + if (field.type !== FieldType.number) { + // show the reducer in the first column + if (i === 0 && options && options.reducer.length > 0) { + const reducer = fieldReducers.get(options.reducer[0]); + return reducer.name; + } + return undefined; + } + if (options?.fields && options.fields.length > 0) { + const f = options.fields.find((f) => f === field.name); + if (f) { + return getFormattedValue(field, options.reducer); + } + return undefined; + } + return getFormattedValue(field, options?.reducer || []); + }); +} + +function getFormattedValue(field: Field, reducer: string[]) { + const fmt = field.display ?? getDisplayProcessor({ field, theme: config.theme2 }); + const calc = reducer[0]; + const v = reduceField({ field, reducers: reducer })[calc]; + return formattedValueToString(fmt(v)); +} diff --git a/public/app/plugins/panel/table/models.gen.ts b/public/app/plugins/panel/table/models.gen.ts index e2c504875a3..9a2f5462fbb 100644 --- a/public/app/plugins/panel/table/models.gen.ts +++ b/public/app/plugins/panel/table/models.gen.ts @@ -16,12 +16,23 @@ export interface PanelOptions { showHeader: boolean; showTypeIcons?: boolean; sortBy?: TableSortByFieldState[]; + footer?: TableFooterCalc; // TODO: should be array (options builder is limited) +} + +export interface TableFooterCalc { + show: boolean; + reducer: string[]; // actually 1 value + fields?: string[]; } export const defaultPanelOptions: PanelOptions = { frameIndex: 0, showHeader: true, showTypeIcons: false, + footer: { + show: false, + reducer: [], + }, }; export interface PanelFieldConfig { diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index 342063f4d63..94f78b415d7 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -1,4 +1,11 @@ -import { PanelPlugin } from '@grafana/data'; +import { + FieldOverrideContext, + FieldType, + getFieldDisplayName, + PanelPlugin, + ReducerID, + standardEditorsRegistry, +} from '@grafana/data'; import { TablePanel } from './TablePanel'; import { PanelOptions, PanelFieldConfig, defaultPanelOptions, defaultPanelFieldConfig } from './models.gen'; import { tableMigrationHandler, tablePanelChangedHandler } from './migrations'; @@ -75,10 +82,52 @@ export const plugin = new PanelPlugin(TablePanel }, }) .setPanelOptions((builder) => { - builder.addBooleanSwitch({ - path: 'showHeader', - name: 'Show header', - description: "To display table's header or not to display", - defaultValue: defaultPanelOptions.showHeader, - }); + builder + .addBooleanSwitch({ + path: 'showHeader', + name: 'Show header', + description: "To display table's header or not to display", + defaultValue: defaultPanelOptions.showHeader, + }) + .addBooleanSwitch({ + path: 'footer.show', + name: 'Show Footer', + description: "To display table's footer or not to display", + defaultValue: defaultPanelOptions.footer?.show, + }) + .addCustomEditor({ + id: 'footer.reducer', + path: 'footer.reducer', + name: 'Calculation', + description: 'Choose a reducer function / calculation', + editor: standardEditorsRegistry.get('stats-picker').editor as any, + defaultValue: [ReducerID.sum], + showIf: (cfg) => cfg.footer?.show, + }) + .addMultiSelect({ + path: 'footer.fields', + name: 'Fields', + description: 'Select the fields that should be calculated', + settings: { + allowCustomValue: false, + options: [], + placeholder: 'All Numeric Fields', + getOptions: async (context: FieldOverrideContext) => { + const options = []; + if (context && context.data && context.data.length > 0) { + const frame = context.data[0]; + for (const field of frame.fields) { + if (field.type === FieldType.number) { + const name = getFieldDisplayName(field, frame, context.data); + const value = field.name; + options.push({ value, label: name } as any); + } + } + } + return options; + }, + }, + defaultValue: '', + showIf: (cfg) => cfg.footer?.show, + }); });