diff --git a/CHANGELOG.md b/CHANGELOG.md index d119a4f58..606e3cb06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#288](https://github.com/kobsio/kobs/pull/288): [resources] Add support to show custom columns for a resource. - [#289](https://github.com/kobsio/kobs/pull/289): Add an option to create a NetworkPolicy via the Helm chart. - [#295](https://github.com/kobsio/kobs/pull/295): [sql] Add `columns` options to set the title and formation of the returned columns from a query. +- [#296](https://github.com/kobsio/kobs/pull/296): [sql] Add support to visualize data via line and area charts. ### Fixed diff --git a/docs/plugins/assets/sql-example.png b/docs/plugins/assets/sql-example.png new file mode 100644 index 000000000..15d664248 Binary files /dev/null and b/docs/plugins/assets/sql-example.png differ diff --git a/docs/plugins/sql.md b/docs/plugins/sql.md index b20154067..baee922cd 100644 --- a/docs/plugins/sql.md +++ b/docs/plugins/sql.md @@ -29,8 +29,9 @@ The following options can be used for a panel with the SQL plugin: | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | -| type | string | The type which should be used to visualize the data. Currently we only support the `table` value. | Yes | -| queries | [[]Query](#query) | A list of queries, which can be selected by the user. | Yes | +| type | string | The type which should be used to visualize the data. This can be `table` or `chart`. | Yes | +| queries | [[]Query](#query) | A list of queries, which can be selected by the user. This is required when the `type` is set to `table`. | No | +| chart | [Chart](#chart) | Settings to render the results of a query in a chart. This is required when the `type` is set to `chart`. | No | ### Query @@ -38,30 +39,158 @@ The following options can be used for a panel with the SQL plugin: | ----- | ---- | ----------- | -------- | | name | string | A name for the SQL query, which is displayed in the select box. | Yes | | query | string | The query which should be run against the configured SQL database. | Yes | -| columns | map | A map of columns to format the returned data for a query. The key must match the returned column name. | Yes | +| columns | map | A map of columns to format the returned data for a query. The key must match the returned column name. | No | ### Column | Field | Type | Description | Required | | ----- | ---- | ----------- | -------- | | title | string | Set a title for the column. | No | -| format | string | Format the results for the column. This can be a string with `{% .value %}` as placeholder for the value or one of the following special keys: `time`. | No | +| unit | string | A unit which should be displayed behind the column value. If this is `time` we automatically try to auto format the column to the users local time. | No | -```yaml ---- -apiVersion: kobs.io/v1 -kind: Dashboard -spec: - rows: - - size: -1 - panels: - - title: User Data - colSpan: 12 - plugin: - name: sql - options: - type: table - queries: - - name: User Data - query: "SELECT * FROM example.users" -``` +### Chart + +| Field | Type | Description | Required | +| ----- | ---- | ----------- | -------- | +| type | string | The chart type. This could be `line` or `area`. | Yes | +| query | string | The query which which results should be used in the chart. | Yes | +| xAxisColumn | string | The column which should be used for the x axis. | Yes | +| xAxisType | string | The type for the x axis. This could be empty or `time`. | No | +| xAxisUnit | string | The unit which should be used for the x axis. | No | +| yAxisColumns | []string | A list of columns which should be shown for the y axis. | Yes | +| yAxisUnit | string | The unit for the y axis. | No | +| yAxisStacked | boolean | When this is `true` the values of the y axis are stacked. | No | +| legend | map | A map of string pairs, to set the displayed title for a column in the legend. The key is the column name as returned by the query and the value is the shown title. | No | + +## Examples + +The following example uses a configured SQL which access the data from a [klogs](klogs.md) ClickHouse instance to show the difference between the duration and upstream service time from the Istio access logs. + +??? note "Dashboard" + + ```yaml + --- + apiVersion: kobs.io/v1 + kind: Dashboard + metadata: + name: latency + namespace: kobs + spec: + rows: + - size: 3 + panels: + - title: Raw Data + colSpan: 6 + rowSpan: 2 + plugin: + name: sql-klogs-clickhouse + options: + type: table + queries: + - name: Duration and Upstream Service Time + query: | + SELECT + toStartOfInterval(timestamp, INTERVAL 60 second) AS time, + avg(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as avg_duration, + avg(fields_number.value[indexOf(fields_number.key, 'content.upstream_service_time')]) as avg_ust, + avg_duration - avg_ust as avg_diff + FROM + logs.logs + WHERE + timestamp >= FROM_UNIXTIME({% .__timeStart %}) + AND timestamp <= FROM_UNIXTIME({% .__timeEnd %}) + AND namespace='myservice' + AND app='myservice' + AND container_name='istio-proxy' + AND match(fields_string.value[indexOf(fields_string.key, 'content.upstream_cluster')], '^inbound.*') + GROUP BY + time + ORDER BY + time + columns: + time: + title: Time + unit: time + avg_duration: + title: Duration + unit: ms + avg_ust: + title: Upstream Service Time + unit: ms + avg_diff: + title: Difference + unit: ms + + - title: Difference + colSpan: 6 + plugin: + name: sql-klogs-clickhouse + options: + type: chart + chart: + type: line + query: | + SELECT + toStartOfInterval(timestamp, INTERVAL 60 second) AS time, + avg(fields_number.value[indexOf(fields_number.key, 'content.duration')]) - avg(fields_number.value[indexOf(fields_number.key, 'content.upstream_service_time')]) as avg_diff + FROM + logs.logs + WHERE + timestamp >= FROM_UNIXTIME({% .__timeStart %}) + AND timestamp <= FROM_UNIXTIME({% .__timeEnd %}) + AND namespace='myservice' + AND app='myservice' + AND container_name='istio-proxy' + AND match(fields_string.value[indexOf(fields_string.key, 'content.upstream_cluster')], '^inbound.*') + GROUP BY + time + ORDER BY + time + xAxisColumn: time + xAxisType: time + yAxisColumns: + - avg_diff + yAxisUnit: ms + yAxisStacked: false + legend: + avg_diff: Difference + + - title: Duration vs Upstream Service Time + colSpan: 6 + plugin: + name: sql-klogs-clickhouse + options: + type: chart + chart: + type: line + query: | + SELECT + toStartOfInterval(timestamp, INTERVAL 60 second) AS time, + avg(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as avg_duration, + avg(fields_number.value[indexOf(fields_number.key, 'content.upstream_service_time')]) as avg_ust + FROM + logs.logs + WHERE + timestamp >= FROM_UNIXTIME({% .__timeStart %}) + AND timestamp <= FROM_UNIXTIME({% .__timeEnd %}) + AND namespace='myservice' + AND app='myservice' + AND container_name='istio-proxy' + AND match(fields_string.value[indexOf(fields_string.key, 'content.upstream_cluster')], '^inbound.*') + GROUP BY + time + ORDER BY + time + xAxisColumn: time + xAxisType: time + yAxisColumns: + - avg_duration + - avg_ust + yAxisUnit: ms + yAxisStacked: false + legend: + avg_duration: Duration + avg_ust: Upstream Service Time + ``` + +![SQL Example](assets/sql-example.png) diff --git a/plugins/sql/src/components/page/PageSQL.tsx b/plugins/sql/src/components/page/PageSQL.tsx index 2a3720d93..8d25e039e 100644 --- a/plugins/sql/src/components/page/PageSQL.tsx +++ b/plugins/sql/src/components/page/PageSQL.tsx @@ -14,26 +14,29 @@ interface IPageSQLProps { const PageSQL: React.FunctionComponent = ({ name, query }: IPageSQLProps) => { const history = useHistory(); - const { isError, isFetching, error, data, refetch } = useQuery(['sql/query', query], async () => { - try { - const response = await fetch(`/api/plugins/sql/${name}/query?query=${encodeURIComponent(query)}`, { - method: 'get', - }); - const json = await response.json(); + const { isError, isFetching, error, data, refetch } = useQuery( + ['sql/query', name, query], + async () => { + try { + const response = await fetch(`/api/plugins/sql/${name}/query?query=${encodeURIComponent(query)}`, { + method: 'get', + }); + const json = await response.json(); - if (response.status >= 200 && response.status < 300) { - return json; - } else { - if (json.error) { - throw new Error(json.error); + if (response.status >= 200 && response.status < 300) { + return json; } else { - throw new Error('An unknown error occured'); + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } } + } catch (err) { + throw err; } - } catch (err) { - throw err; - } - }); + }, + ); if (isFetching) { return ( @@ -47,7 +50,7 @@ const PageSQL: React.FunctionComponent = ({ name, query }: IPageS return ( history.push('/')}>Home diff --git a/plugins/sql/src/components/panel/Panel.tsx b/plugins/sql/src/components/panel/Panel.tsx index 0aad8eda3..fd25e219a 100644 --- a/plugins/sql/src/components/panel/Panel.tsx +++ b/plugins/sql/src/components/panel/Panel.tsx @@ -3,6 +3,7 @@ import React, { memo } from 'react'; import { IPluginPanelProps, PluginOptionsMissing } from '@kobsio/plugin-core'; import { IPanelOptions } from '../../utils/interfaces'; import SQL from './SQL'; +import SQLChart from './SQLChart'; interface IPanelProps extends IPluginPanelProps { options?: IPanelOptions; @@ -15,22 +16,45 @@ export const Panel: React.FunctionComponent = ({ times, options, }: IPanelProps) => { - if (!options || !options.type) { + if (options && options.type === 'table' && options.queries) { + return ; + } + + if ( + options && + options.type === 'chart' && + options.chart && + options.chart.type && + options.chart.query && + options.chart.xAxisColumn && + options.chart.yAxisColumns + ) { return ( - ); } - if (options.type === 'table' && options.queries) { - return ; - } - - return null; + return ( + + ); }; export default memo(Panel, (prevProps, nextProps) => { diff --git a/plugins/sql/src/components/panel/SQL.tsx b/plugins/sql/src/components/panel/SQL.tsx index c2e3a1f26..154256e69 100644 --- a/plugins/sql/src/components/panel/SQL.tsx +++ b/plugins/sql/src/components/panel/SQL.tsx @@ -28,7 +28,7 @@ const SQL: React.FunctionComponent = ({ name, title, description, que const [selectedQueryIndex, setSelectedQueryIndex] = useState(0); const { isError, isFetching, isLoading, error, data, refetch } = useQuery( - ['sql/query', queries, selectedQueryIndex], + ['sql/query', name, queries, selectedQueryIndex], async () => { try { if (!queries[selectedQueryIndex].query) { @@ -104,7 +104,7 @@ const SQL: React.FunctionComponent = ({ name, title, description, que > => refetch()}> diff --git a/plugins/sql/src/components/panel/SQLChart.tsx b/plugins/sql/src/components/panel/SQLChart.tsx new file mode 100644 index 000000000..c42ac564e --- /dev/null +++ b/plugins/sql/src/components/panel/SQLChart.tsx @@ -0,0 +1,117 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { ILegend, ISQLData } from '../../utils/interfaces'; +import { PluginCard } from '@kobsio/plugin-core'; +import SQLChartActions from './SQLChartActions'; +import SQLChartLine from './SQLChartLine'; +import SQLChartLineLegend from './SQLChartLineLegend'; + +interface ISQLChartProps { + name: string; + title: string; + description?: string; + type: string; + query: string; + xAxisColumn: string; + xAxisType?: string; + xAxisUnit?: string; + yAxisColumns: string[]; + yAxisUnit?: string; + yAxisStacked?: boolean; + legend?: ILegend; +} + +const SQLChart: React.FunctionComponent = ({ + name, + title, + description, + type, + query, + xAxisColumn, + xAxisType, + xAxisUnit, + yAxisColumns, + yAxisUnit, + yAxisStacked, + legend, +}: ISQLChartProps) => { + const { isError, isFetching, isLoading, error, data, refetch } = useQuery( + ['sql/query', name, query], + async () => { + try { + const response = await fetch(`/api/plugins/sql/${name}/query?query=${encodeURIComponent(query)}`, { + method: 'get', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + { + keepPreviousData: true, + }, + ); + + return ( + } + > + {isLoading ? ( +
+ +
+ ) : isError ? ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ) : data && (type === 'line' || type === 'area') ? ( + +
+ +
+ +
+ +
+
+ ) : null} +
+ ); +}; + +export default SQLChart; diff --git a/plugins/sql/src/components/panel/SQLChartActions.tsx b/plugins/sql/src/components/panel/SQLChartActions.tsx new file mode 100644 index 000000000..028a14ea7 --- /dev/null +++ b/plugins/sql/src/components/panel/SQLChartActions.tsx @@ -0,0 +1,35 @@ +import { CardActions, Dropdown, DropdownItem, KebabToggle, Spinner } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; + +interface ISQLChartActionsProps { + name: string; + query: string; + isFetching: boolean; +} + +export const SQLChartActions: React.FunctionComponent = ({ + name, + query, + isFetching, +}: ISQLChartActionsProps) => { + const [show, setShow] = useState(false); + + return ( + + {isFetching ? ( + + ) : ( + setShow(!show)} />} + isOpen={show} + isPlain={true} + position="right" + dropdownItems={[Explore} />]} + /> + )} + + ); +}; + +export default SQLChartActions; diff --git a/plugins/sql/src/components/panel/SQLChartLine.tsx b/plugins/sql/src/components/panel/SQLChartLine.tsx new file mode 100644 index 000000000..ce0017d16 --- /dev/null +++ b/plugins/sql/src/components/panel/SQLChartLine.tsx @@ -0,0 +1,122 @@ +import { Datum, ResponsiveLineCanvas, Serie } from '@nivo/line'; +import React from 'react'; + +import { CHART_THEME, COLOR_SCALE, ChartTooltip } from '@kobsio/plugin-core'; +import { ILegend, ISQLData, ISQLDataRow } from '../../utils/interfaces'; + +const getSeriesData = ( + rows: ISQLDataRow[], + xAxisColumn: string, + xAxisType: string | undefined, + yAxisColumns: string[], +): Serie[] => { + const series: Serie[] = []; + + for (const yAxisColumn of yAxisColumns) { + const data: Datum[] = []; + + for (const row of rows) { + data.push({ + x: xAxisType === 'time' ? new Date(row[xAxisColumn] as string) : (row[xAxisColumn] as number), + y: row.hasOwnProperty(yAxisColumn) ? (row[yAxisColumn] as number) : null, + }); + } + + series.push({ + data: data, + id: yAxisColumn, + }); + } + + return series; +}; + +interface ISQLChartLineProps { + data: ISQLData; + type: string; + xAxisColumn: string; + xAxisType?: string; + xAxisUnit?: string; + yAxisColumns: string[]; + yAxisUnit?: string; + yAxisStacked?: boolean; + legend?: ILegend; +} + +export const SQLChartLine: React.FunctionComponent = ({ + data, + type, + xAxisColumn, + xAxisType, + xAxisUnit, + yAxisColumns, + yAxisUnit, + yAxisStacked, + legend, +}: ISQLChartLineProps) => { + const series = data.rows ? getSeriesData(data.rows, xAxisColumn, xAxisType, yAxisColumns) : []; + + return ( + -.2f', + tickValues: + series.length > 0 + ? series[0].data + .filter( + (datum, index) => + index !== 0 && + index !== series[0].data.length - 1 && + (index + 1) % + (Math.floor(series[0].data.length / 10) > 2 ? Math.floor(series[0].data.length / 10) : 2) === + 0, + ) + .map((datum) => datum.x) + : undefined, + }} + axisLeft={{ + format: '>-.2f', + legend: yAxisUnit, + legendOffset: -40, + legendPosition: 'middle', + }} + colors={COLOR_SCALE} + curve="monotoneX" + data={series} + enableArea={type === 'area'} + enableGridX={false} + enableGridY={true} + enablePoints={false} + xFormat={xAxisType === 'time' ? 'time:%Y-%m-%d %H:%M:%S' : '>-.4f'} + lineWidth={1} + margin={{ bottom: 25, left: 50, right: 0, top: 0 }} + theme={CHART_THEME} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + tooltip={(tooltip) => { + return ( + + ); + }} + xScale={{ max: 'auto', min: 'auto', type: xAxisType === 'time' ? 'time' : 'linear' }} + yScale={{ + max: 'auto', + min: 'auto', + stacked: yAxisStacked ? true : false, + type: 'linear', + }} + yFormat=">-.4f" + /> + ); +}; + +export default SQLChartLine; diff --git a/plugins/sql/src/components/panel/SQLChartLineLegend.tsx b/plugins/sql/src/components/panel/SQLChartLineLegend.tsx new file mode 100644 index 000000000..52c4f6175 --- /dev/null +++ b/plugins/sql/src/components/panel/SQLChartLineLegend.tsx @@ -0,0 +1,121 @@ +import { Button, ButtonVariant } from '@patternfly/react-core'; +import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; +import { SquareIcon } from '@patternfly/react-icons'; + +import { ILegend, ISQLData, ISQLDataRow } from '../../utils/interfaces'; +import { getColor } from '@kobsio/plugin-core'; + +const calcMin = (column: string, rows: ISQLDataRow[] | undefined, unit: string | undefined): string => { + if (!rows) { + return ''; + } + + let min = 0; + + for (let i = 0; i < rows.length; i++) { + if (i === 0) { + min = rows[i][column] as number; + } + + if (rows[i][column] < min) { + min = rows[i][column] as number; + } + } + + return `${min} ${unit}`; +}; + +const calcMax = (column: string, rows: ISQLDataRow[] | undefined, unit: string | undefined): string => { + if (!rows) { + return ''; + } + + let max = 0; + + for (let i = 0; i < rows.length; i++) { + if (i === 0) { + max = rows[i][column] as number; + } + + if (rows[i][column] > max) { + max = rows[i][column] as number; + } + } + + return `${max} ${unit}`; +}; + +const calcAvg = (column: string, rows: ISQLDataRow[] | undefined, unit: string | undefined): string => { + if (!rows) { + return ''; + } + + let sum = 0; + + for (let i = 0; i < rows.length; i++) { + sum = sum + (rows[i][column] as number); + } + + return `${sum / rows.length} ${unit}`; +}; + +interface ISQLChartLineLegendProps { + data: ISQLData; + yAxisColumns: string[]; + yAxisUnit?: string; + legend?: ILegend; +} + +const SQLChartLineLegend: React.FunctionComponent = ({ + data, + yAxisColumns, + yAxisUnit, + legend, +}: ISQLChartLineLegendProps) => { + return ( + + + + Name + Min + Max + Avg + Current + + + + {yAxisColumns.map((column, index) => ( + + + + + + {calcMin(column, data.rows, yAxisUnit)} + + + {calcMax(column, data.rows, yAxisUnit)} + + + {calcAvg(column, data.rows, yAxisUnit)} + + + {data.rows && data.rows[data.rows?.length - 1].hasOwnProperty(column) + ? `${data.rows[data.rows?.length - 1][column]} ${yAxisUnit}` + : ''} + + + ))} + + + ); +}; + +export default SQLChartLineLegend; diff --git a/plugins/sql/src/components/panel/SQLTable.tsx b/plugins/sql/src/components/panel/SQLTable.tsx index f5e7209d0..a42570f82 100644 --- a/plugins/sql/src/components/panel/SQLTable.tsx +++ b/plugins/sql/src/components/panel/SQLTable.tsx @@ -36,7 +36,7 @@ const SQLTable: React.FunctionComponent = ({ rows, columns, colu ? renderCellValue( row[column], columnOptions && columnOptions.hasOwnProperty(column) - ? columnOptions[column].format + ? columnOptions[column].unit : undefined, ) : ''} diff --git a/plugins/sql/src/utils/helpers.ts b/plugins/sql/src/utils/helpers.ts index 116b4d1fc..c21e44c34 100644 --- a/plugins/sql/src/utils/helpers.ts +++ b/plugins/sql/src/utils/helpers.ts @@ -11,20 +11,18 @@ export const getInitialOptions = (search: string): IOptions => { }; }; -export const renderCellValue = (value: string | number | string[] | number[], format?: string): string => { - let formattedValue = `${value}`; - +export const renderCellValue = (value: string | number | string[] | number[], unit?: string): string => { if (Array.isArray(value)) { - formattedValue = `[${value.join(', ')}]`; + return `[${value.join(', ')}] ${unit}`; } - if (format) { - if (format === 'time') { - formatTime(Math.floor(new Date(formattedValue).getTime() / 1000)); + if (unit) { + if (unit === 'time') { + return formatTime(Math.floor(new Date(value).getTime() / 1000)); } else { - formattedValue = format.replaceAll(`{% .value %}`, formattedValue); + return `${value} ${unit}`; } } - return formattedValue; + return `${value}`; }; diff --git a/plugins/sql/src/utils/interfaces.ts b/plugins/sql/src/utils/interfaces.ts index f9da7fd2c..6d4af523f 100644 --- a/plugins/sql/src/utils/interfaces.ts +++ b/plugins/sql/src/utils/interfaces.ts @@ -7,6 +7,7 @@ export interface IOptions { export interface IPanelOptions { type?: string; queries?: IQuery[]; + chart?: IChart; } export interface IQuery { @@ -21,7 +22,23 @@ export interface IColumns { export interface IColumn { title?: string; - format?: string; + unit?: string; +} + +export interface IChart { + type?: string; + query?: string; + xAxisColumn?: string; + xAxisType?: string; + xAxisUnit?: string; + yAxisColumns?: string[]; + yAxisUnit?: string; + yAxisStacked?: boolean; + legend?: ILegend; +} + +export interface ILegend { + [key: string]: string; } // ISQLData is the interface of the data returned from our Go API for the get query results call.