Merge pull request #5728 from influxdata/5668/better_queries_table

feat(ui/admin/queries): Improve InfluxDB Admin | Queries page
pull/5731/head
Pavel Závora 2021-04-19 12:05:33 +02:00 committed by GitHub
commit a6e3ed5d8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 421 additions and 54 deletions

View File

@ -16,6 +16,7 @@
1. [#5712](https://github.com/influxdata/chronograf/pull/5712): Allow to change write precission.
1. [#5710](https://github.com/influxdata/chronograf/pull/5710): Add PKCE to OAuth integrations.
1. [#5713](https://github.com/influxdata/chronograf/pull/5713): Support GitHub Enterprise in the existing GitHub OAuth integration.
1. [#5728](https://github.com/influxdata/chronograf/pull/5728): Improve InfluxDB Admin | Queries page.
### Bug Fixes

View File

@ -177,7 +177,13 @@ export const setQueryToKill = queryIDToKill => ({
export const loadQueries = queries => ({
type: 'INFLUXDB_LOAD_QUERIES',
payload: {
queries,
queries: [...queries],
},
})
export const setQueriesSort = queriesSort => ({
type: 'INFLUXDB_SET_QUERIES_SORT',
payload: {
queriesSort,
},
})

View File

@ -1,39 +1,69 @@
import React from 'react'
import PropTypes from 'prop-types'
import PropTypes, {string} from 'prop-types'
import QueryRow from 'src/admin/components/QueryRow'
import {QUERIES_TABLE} from 'src/admin/constants/tableSizing'
const QueriesTable = ({queries, onKillQuery}) => (
<div>
<div className="panel panel-solid">
<div className="panel-body">
<table className="table v-center admin-table table-highlight">
<thead>
<tr>
<th style={{width: `${QUERIES_TABLE.colDatabase}px`}}>
Database
</th>
<th>Query</th>
<th style={{width: `${QUERIES_TABLE.colRunning}px`}}>Running</th>
<th style={{width: `${QUERIES_TABLE.colKillQuery}px`}} />
</tr>
</thead>
<tbody>
{queries.map(q => (
<QueryRow key={q.id} query={q} onKill={onKillQuery} />
))}
</tbody>
</table>
</div>
</div>
</div>
)
const QueriesTable = ({queries, queriesSort, changeSort, onKillQuery}) => {
let timeSortClass = 'col--sort-next-desc'
let dbSortClass = 'col--sort-next-asc'
let newTimeSort = '-time'
let newDBSort = '+database'
switch (queriesSort) {
case '-time':
timeSortClass = 'col--sort-desc'
newTimeSort = '+time'
break
case '+time':
timeSortClass = 'col--sort-asc'
newTimeSort = '-time'
break
case '-database':
dbSortClass = 'col--sort-desc'
newDBSort = '+database'
break
case '+database':
dbSortClass = 'col--sort-asc'
newDBSort = '-database'
break
}
return (
<table className="table v-center admin-table table-highlight">
<thead>
<tr>
<th
style={{width: `${QUERIES_TABLE.colDatabase}px`}}
className={`col--sortable ${dbSortClass}`}
onClick={() => changeSort(newDBSort)}
>
<div>Database</div>
</th>
<th>Query</th>
<th
style={{width: `${QUERIES_TABLE.colRunning}px`}}
className={`col--sortable ${timeSortClass}`}
onClick={() => changeSort(newTimeSort)}
>
Running
</th>
<th style={{width: `${QUERIES_TABLE.colKillQuery}px`}} />
</tr>
</thead>
<tbody>
{queries.map(q => (
<QueryRow key={q.id} query={q} onKill={onKillQuery} />
))}
</tbody>
</table>
)
}
const {arrayOf, func, shape} = PropTypes
QueriesTable.propTypes = {
queries: arrayOf(shape()),
queriesSort: string,
changeSort: func,
onConfirm: func,
onKillQuery: func,
}

View File

@ -1,13 +1,3 @@
export const TIMES = [
{test: /ns/, magnitude: 0},
{test: /µs/, magnitude: 1},
{test: /u/, magnitude: 1},
{test: /^\d*ms/, magnitude: 2},
{test: /^\d*s/, magnitude: 3},
{test: /^\d*m\d*s/, magnitude: 4},
{test: /^\d*h\d*m\d*s/, magnitude: 5},
]
export const NEW_DEFAULT_USER = {
name: '',
password: '',

View File

@ -11,33 +11,69 @@ import {showDatabases, showQueries} from 'shared/apis/metaQuery'
import QueriesTable from 'src/admin/components/QueriesTable'
import showDatabasesParser from 'shared/parsing/showDatabases'
import showQueriesParser from 'shared/parsing/showQueries'
import {TIMES} from 'src/admin/constants'
import {notifyQueriesError} from 'shared/copy/notifications'
import {ErrorHandling} from 'src/shared/decorators/errors'
import AutoRefreshDropdown from 'src/shared/components/dropdown_auto_refresh/AutoRefreshDropdown'
import {
loadQueries as loadQueriesAction,
setQueryToKill as setQueryToKillAction,
setQueriesSort as setQueriesSortAction,
killQueryAsync,
} from 'src/admin/actions/influxdb'
import {notify as notifyAction} from 'shared/actions/notifications'
class QueriesPage extends Component {
constructor(props) {
super(props)
this.state = {
updateInterval: 5000,
}
}
componentDidMount() {
this.updateQueries()
const updateInterval = 5000
this.intervalID = setInterval(this.updateQueries, updateInterval)
if (this.state.updateInterval > 0) {
this.intervalID = setInterval(
this.updateQueries,
this.state.updateInterval
)
}
}
componentWillUnmount() {
clearInterval(this.intervalID)
if (this.intervalID) {
clearInterval(this.intervalID)
}
}
render() {
const {queries} = this.props
const {queries, queriesSort, changeSort} = this.props
const {updateInterval, title} = this.state
return <QueriesTable queries={queries} onKillQuery={this.handleKillQuery} />
return (
<div className="panel panel-solid">
<div className="panel-heading">
<h2 className="panel-title">{title}</h2>
<div style={{float: 'right'}}>
<AutoRefreshDropdown
selected={updateInterval}
onChoose={this.changeRefreshInterval}
onManualRefresh={this.updateQueries}
showManualRefresh={true}
/>
</div>
</div>
<div className="panel-body">
<QueriesTable
queries={queries}
queriesSort={queriesSort}
changeSort={changeSort}
onKillQuery={this.handleKillQuery}
/>
</div>
</div>
)
}
updateQueries = () => {
@ -45,9 +81,17 @@ class QueriesPage extends Component {
showDatabases(source.links.proxy).then(resp => {
const {databases, errors} = showDatabasesParser(resp.data)
if (errors.length) {
this.setState(state => ({...state, title: ''}))
errors.forEach(message => notify(notifyQueriesError(message)))
return
}
this.setState(state => ({
...state,
title:
databases.length === 1
? '1 Database'
: `${databases.length} Databases`,
}))
const fetches = databases.map(db => showQueries(source.links.proxy, db))
@ -72,18 +116,21 @@ class QueriesPage extends Component {
})
const queries = uniqBy(flatten(allQueries), q => q.id)
// sorting queries by magnitude, so generally longer queries will appear atop the list
const sortedQueries = queries.sort((a, b) => {
const aTime = TIMES.find(t => a.duration.match(t.test))
const bTime = TIMES.find(t => b.duration.match(t.test))
return +aTime.magnitude <= +bTime.magnitude
})
loadQueries(sortedQueries)
loadQueries(queries)
})
})
}
changeRefreshInterval = ({milliseconds: updateInterval}) => {
this.setState(state => ({...state, updateInterval}))
if (this.intervalID) {
clearInterval(this.intervalID)
this.intervalID = undefined
}
if (updateInterval > 0) {
this.updateQueries()
this.intervalID = setInterval(this.updateQueries, updateInterval)
}
}
handleKillQuery = query => {
const {source, killQuery} = this.props
@ -100,21 +147,27 @@ QueriesPage.propTypes = {
}),
}),
queries: arrayOf(shape()),
queriesSort: string,
loadQueries: func,
queryIDToKill: string,
setQueryToKill: func,
changeSort: func,
killQuery: func,
notify: func.isRequired,
}
const mapStateToProps = ({adminInfluxDB: {queries, queryIDToKill}}) => ({
const mapStateToProps = ({
adminInfluxDB: {queries, queriesSort, queryIDToKill},
}) => ({
queries,
queriesSort,
queryIDToKill,
})
const mapDispatchToProps = dispatch => ({
loadQueries: bindActionCreators(loadQueriesAction, dispatch),
setQueryToKill: bindActionCreators(setQueryToKillAction, dispatch),
changeSort: bindActionCreators(setQueriesSortAction, dispatch),
killQuery: bindActionCreators(killQueryAsync, dispatch),
notify: bindActionCreators(notifyAction, dispatch),
})

View File

@ -6,12 +6,45 @@ import {
NEW_EMPTY_RP,
} from 'src/admin/constants'
import uuid from 'uuid'
import {parseDuration, compareDurations} from 'src/utils/influxDuration'
const querySorters = {
'+time'(queries) {
queries.forEach(x => (x._pd = parseDuration(x.duration)))
return queries.sort((a, b) => {
return compareDurations(a._pd, b._pd)
})
},
'-time'(queries) {
queries.forEach(x => (x._pd = parseDuration(x.duration)))
return queries.sort((a, b) => {
return -compareDurations(a._pd, b._pd)
})
},
'+database'(queries) {
queries.forEach(x => (x._pd = parseDuration(x.duration)))
return queries.sort((a, b) => {
return a.database.localeCompare(b.database)
})
},
'-database'(queries) {
queries.forEach(x => (x._pd = parseDuration(x.duration)))
return queries.sort((a, b) => {
return -a.database.localeCompare(b.database)
})
},
}
const identity = x => x
function sortQueries(queries, queriesSort) {
return (querySorters[queriesSort] || identity)(queries)
}
const initialState = {
users: [],
roles: [],
permissions: [],
queries: [],
queriesSort: '-time',
queryIDToKill: null,
databases: [],
}
@ -274,7 +307,18 @@ const adminInfluxDB = (state = initialState, action) => {
}
case 'INFLUXDB_LOAD_QUERIES': {
return {...state, ...action.payload}
return {
...state,
queries: sortQueries(action.payload.queries, state.queriesSort),
}
}
case 'INFLUXDB_SET_QUERIES_SORT': {
const queriesSort = action.payload.queriesSort
return {
...state,
queriesSort,
queries: sortQueries(state.queries, queriesSort),
}
}
case 'INFLUXDB_FILTER_USERS': {

View File

@ -41,6 +41,39 @@ table > thead > tr > th.admin-table--left-offset {
padding-left: 15px;
}
.admin-table > thead > tr > th.col--sort-asc,
.admin-table > thead > tr > th.col--sort-desc {
color: #22adf6;
}
.admin-table > thead > tr > th.col--sortable {
position: absolute;
&:after {
font-family: 'icomoon';
content: '\e902';
font-size: 13px;
position: absolute;
top: 50%;
right: 6px;
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.25s ease, color 0.25s ease, transform 0.25s ease;
}
}
.admin-table > thead > tr > th.col--sortable.col--sort-asc:after,
.admin-table > thead > tr > th.col--sortable.col--sort-next-asc:hover:after {
transform: translateY(-50%) rotate(180deg);
opacity: 1;
}
.admin-table > thead > tr > th.col--sortable.col--sort-desc:after,
.admin-table > thead > tr > th.col--sortable.col--sort-next-desc:hover:after {
transform: translateY(-50%) rotate(0deg);
opacity: 1;
}
.admin-table > thead > tr > th.col--sortable.col--sort-next-asc:after {
transform: translateY(-50%) rotate(180deg);
}
table > tbody > tr.admin-table--edit-row,
table > tbody > tr.admin-table--edit-row:hover,
table.table-highlight > tbody > tr.admin-table--edit-row,

View File

@ -0,0 +1,59 @@
/**
* InfluxDuration represents components of InfluxDB duration.
* See https://docs.influxdata.com/influxdb/v1.8/query_language/spec/#durations
*/
export type InfluxDuration = [
w: number,
d: number,
h: number,
m: number,
s: number,
ms: number,
us: number,
ns: number
]
const durationPartIndex: Record<string, number> = {
w: 0,
d: 1,
h: 2,
m: 3,
s: 4,
ms: 5,
u: 6,
µs: 6,
ns: 7,
}
/**
* ParseDuration parses string into a InfluxDuration, unknown duration parts are simply ignored.
* @param duration duration literal per https://docs.influxdata.com/influxdb/v1.8/query_language/spec/#durations
* @returns InfluxDuration
*/
export function parseDuration(duration: string): InfluxDuration {
const retVal = new Array<number>(8).fill(0) as InfluxDuration
const regExp = /([0-9]+)([^0-9]+)/g
let matched: string[]
while ((matched = regExp.exec(duration)) !== null) {
const index = durationPartIndex[matched[2]]
if (index === undefined) {
// ignore unknown part
continue
}
retVal[index] = parseInt(matched[1], 10)
}
return retVal
}
/**
* CompareDurations implements sort comparator for InfluxDuration instances.
*/
export function compareDurations(a: InfluxDuration, b: InfluxDuration): number {
let i = 0
for (; i < 8; i++) {
if (a[i] !== b[i]) {
return a[i] - b[i]
}
}
return 0
}

View File

@ -21,6 +21,8 @@ import {
filterUsers,
addDatabaseDeleteCode,
removeDatabaseDeleteCode,
loadQueries,
setQueriesSort,
} from 'src/admin/actions/influxdb'
import {
@ -375,4 +377,72 @@ describe('Admin.InfluxDB.Reducers', () => {
expect(actual.permissions).toEqual(expected.permissions)
})
describe('Queries', () => {
it('it sorts queries by time descending OOTB', () => {
const queries = [
{id: 1, database: 'a', duration: '11µs'},
{id: 2, database: 'b', duration: '36µs'},
]
const actual = reducer(undefined, loadQueries(queries))
expect(actual.queriesSort).toEqual('-time')
expect(actual.queries).toHaveLength(2)
expect(actual.queries[0].id).toEqual(2)
expect(actual.queries[1].id).toEqual(1)
})
it('it sorts queries by database', () => {
const queries = [
{id: 2, database: 'b', duration: '16µs'},
{id: 1, database: 'a', duration: '21µs'},
]
let actual = reducer(undefined, loadQueries(queries))
actual = reducer(actual, setQueriesSort('+database'))
expect(actual.queriesSort).toEqual('+database')
expect(actual.queries).toHaveLength(2)
expect(actual.queries[0].id).toEqual(1)
expect(actual.queries[1].id).toEqual(2)
actual = reducer(actual, setQueriesSort('-database'))
expect(actual.queriesSort).toEqual('-database')
expect(actual.queries).toHaveLength(2)
expect(actual.queries[0].id).toEqual(2)
expect(actual.queries[1].id).toEqual(1)
const queries2 = [
{id: 3, database: 'c', duration: '26µs'},
{id: 1, database: 'x', duration: '11µs'},
{id: 2, database: 'd', duration: '22µs'},
]
actual = reducer(actual, loadQueries(queries2))
expect(actual.queries).toHaveLength(3)
expect(actual.queries[0].id).toEqual(1)
expect(actual.queries[1].id).toEqual(2)
expect(actual.queries[2].id).toEqual(3)
})
it('it sorts queries by time', () => {
const queries = [
{id: 2, database: 'b', duration: '36µs'},
{id: 1, database: 'a', duration: '11µs'},
]
let actual = reducer(undefined, loadQueries(queries))
actual = reducer(actual, setQueriesSort('+time'))
expect(actual.queriesSort).toEqual('+time')
expect(actual.queries).toHaveLength(2)
expect(actual.queries[0].id).toEqual(1)
expect(actual.queries[1].id).toEqual(2)
actual = reducer(actual, setQueriesSort('-time'))
expect(actual.queriesSort).toEqual('-time')
expect(actual.queries).toHaveLength(2)
expect(actual.queries[0].id).toEqual(2)
expect(actual.queries[1].id).toEqual(1)
const queries2 = [
{id: 3, database: 'c', duration: '36µs'},
{id: 1, database: 'x', duration: '11µs'},
{id: 2, database: 'd', duration: '12µs'},
]
actual = reducer(actual, loadQueries(queries2))
expect(actual.queries).toHaveLength(3)
expect(actual.queries[0].id).toEqual(3)
expect(actual.queries[1].id).toEqual(2)
expect(actual.queries[2].id).toEqual(1)
})
})
})

View File

@ -0,0 +1,81 @@
import {
parseDuration,
InfluxDuration,
compareDurations,
} from 'src/utils/influxDuration'
describe('InfluxDuration', () => {
describe('parseDuration', () => {
;[
{
str: '',
duration: [0, 0, 0, 0, 0, 0, 0, 0] as InfluxDuration,
},
{
str: '1w',
duration: [1, 0, 0, 0, 0, 0, 0, 0] as InfluxDuration,
},
{
str: '1d',
duration: [0, 1, 0, 0, 0, 0, 0, 0] as InfluxDuration,
},
{
str: '1h',
duration: [0, 0, 1, 0, 0, 0, 0, 0] as InfluxDuration,
},
{
str: '1m',
duration: [0, 0, 0, 1, 0, 0, 0, 0] as InfluxDuration,
},
{
str: '1s',
duration: [0, 0, 0, 0, 1, 0, 0, 0] as InfluxDuration,
},
{
str: '1ms',
duration: [0, 0, 0, 0, 0, 1, 0, 0] as InfluxDuration,
},
{
str: '1µs',
duration: [0, 0, 0, 0, 0, 0, 1, 0] as InfluxDuration,
},
{
str: '1u',
duration: [0, 0, 0, 0, 0, 0, 1, 0] as InfluxDuration,
},
{
str: '1ns',
duration: [0, 0, 0, 0, 0, 0, 0, 1] as InfluxDuration,
},
{
str: '11w22d33h44m55s66ms77u88ns',
duration: [11, 22, 33, 44, 55, 66, 77, 88] as InfluxDuration,
},
].forEach(({str, duration}) => {
it(`parses '${str}'`, () => {
const retVal = parseDuration(str)
expect(retVal).toEqual(duration)
})
})
})
describe('compareDurations', () => {
const units = ['w', 'd', 'h', 'm', 's', 'ms', 'u', 'ns']
it('compares simple durations', () => {
units.forEach(unit => {
const x = parseDuration(`1${unit}`)
const y = parseDuration(`2${unit}`)
expect(compareDurations(x, x)).toEqual(0)
expect(compareDurations(x, y)).toBeLessThan(0)
expect(compareDurations(y, x)).toBeGreaterThan(0)
})
})
it('compares 3-unit durations', () => {
units.slice(0, 5).forEach(unit => {
const x = parseDuration(`1${unit}2u3ns`)
const y = parseDuration(`1${unit}2u14ns`)
expect(compareDurations(x, x)).toEqual(0)
expect(compareDurations(x, y)).toBeLessThan(0)
expect(compareDurations(y, x)).toBeGreaterThan(0)
})
})
})
})