diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 25d27cef8..9bca4ab16 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -19,13 +19,13 @@ "@fortawesome/fontawesome-svg-core": "^1.2.14", "@fortawesome/free-solid-svg-icons": "^5.7.1", "@fortawesome/react-fontawesome": "^0.1.4", + "@nexucis/fuzzy": "^0.2.2", "@reach/router": "^1.2.1", "bootstrap": "^4.6.0", "codemirror-promql": "^0.16.0", "css.escape": "^1.5.1", "downshift": "^3.4.8", "enzyme-to-json": "^3.4.3", - "fuzzy": "^0.1.3", "i": "^0.3.6", "jquery": "^3.5.1", "jquery.flot.tooltip": "^0.9.0", diff --git a/web/ui/react-app/src/pages/flags/Flags.test.tsx b/web/ui/react-app/src/pages/flags/Flags.test.tsx index 6c8c254c7..4fdc8519f 100644 --- a/web/ui/react-app/src/pages/flags/Flags.test.tsx +++ b/web/ui/react-app/src/pages/flags/Flags.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { FlagsContent } from './Flags'; -import { Table } from 'reactstrap'; +import { Input, Table } from 'reactstrap'; import toJson from 'enzyme-to-json'; const sampleFlagsResponse = { @@ -55,11 +55,60 @@ describe('Flags', () => { striped: true, }); }); + it('should not fail if data is missing', () => { expect(shallow()).toHaveLength(1); }); + it('should match snapshot', () => { const w = shallow(); expect(toJson(w)).toMatchSnapshot(); }); + + it('is sorted by flag by default', (): void => { + const w = shallow(); + const td = w + .find('tbody') + .find('td') + .find('span') + .first(); + expect(td.html()).toBe('--alertmanager.notification-queue-capacity'); + }); + + it('sorts', (): void => { + const w = shallow(); + const th = w + .find('thead') + .find('td') + .filterWhere((td): boolean => td.hasClass('Flag')); + th.simulate('click'); + const td = w + .find('tbody') + .find('td') + .find('span') + .first(); + expect(td.html()).toBe('--web.user-assets'); + }); + + it('filters by flag name', (): void => { + const w = shallow(); + const input = w.find(Input); + input.simulate('change', { target: { value: 'timeout' } }); + const tds = w + .find('tbody') + .find('td') + .filterWhere(code => code.hasClass('flag-item')); + expect(tds.length).toEqual(3); + }); + + it('filters by flag value', (): void => { + const w = shallow(); + const input = w.find(Input); + input.simulate('change', { target: { value: '10s' } }); + const tds = w + .find('tbody') + .find('td') + .filterWhere(code => code.hasClass('flag-value')); + expect(tds.length).toEqual(1); + }); }); diff --git a/web/ui/react-app/src/pages/flags/Flags.tsx b/web/ui/react-app/src/pages/flags/Flags.tsx index 60b67ae77..06e2434b8 100644 --- a/web/ui/react-app/src/pages/flags/Flags.tsx +++ b/web/ui/react-app/src/pages/flags/Flags.tsx @@ -1,10 +1,18 @@ -import React, { FC } from 'react'; +import React, { ChangeEvent, FC, useState } from 'react'; import { RouteComponentProps } from '@reach/router'; -import { Table } from 'reactstrap'; +import { Input, InputGroup, Table } from 'reactstrap'; import { withStatusIndicator } from '../../components/withStatusIndicator'; import { useFetch } from '../../hooks/useFetch'; import { usePathPrefix } from '../../contexts/PathPrefixContext'; import { API_PATH } from '../../constants/constants'; +import { faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import sanitizeHTML from 'sanitize-html'; +import { Fuzzy, FuzzyResult } from '@nexucis/fuzzy'; + +const fuz = new Fuzzy({ pre: '', post: '', shouldSort: true }); +const flagSeparator = '||'; interface FlagMap { [key: string]: string; @@ -14,18 +22,99 @@ interface FlagsProps { data?: FlagMap; } +const compareAlphaFn = (keys: boolean, reverse: boolean) => ( + [k1, v1]: [string, string], + [k2, v2]: [string, string] +): number => { + const a = keys ? k1 : v1; + const b = keys ? k2 : v2; + const reverser = reverse ? -1 : 1; + return reverser * a.localeCompare(b); +}; + +const getSortIcon = (b: boolean | undefined): IconDefinition => { + if (b === undefined) { + return faSort; + } + if (b) { + return faSortDown; + } + return faSortUp; +}; + +interface SortState { + name: string; + alpha: boolean; + focused: boolean; +} + export const FlagsContent: FC = ({ data = {} }) => { + const initialSearch = ''; + const [searchState, setSearchState] = useState(initialSearch); + const initialSort: SortState = { + name: 'Flag', + alpha: true, + focused: true, + }; + const [sortState, setSortState] = useState(initialSort); + const searchable = Object.entries(data) + .sort(compareAlphaFn(sortState.name === 'Flag', !sortState.alpha)) + .map(([flag, value]) => `--${flag}${flagSeparator}${value}`); + let filtered = searchable; + if (searchState.length > 0) { + filtered = fuz.filter(searchState, searchable).map((value: FuzzyResult) => value.rendered); + } return ( <>

Command-Line Flags

- + + ): void => { + setSearchState(target.value); + }} + /> + +
+ + + {['Flag', 'Value'].map((col: string) => ( + + ))} + + - {Object.keys(data).map(key => ( - - - - - ))} + {filtered.map((result: string) => { + const [flagMatchStr, valueMatchStr] = result.split(flagSeparator); + const sanitizeOpts = { allowedTags: ['strong'] }; + return ( + + + + + ); + })}
+ setSortState({ + name: col, + focused: true, + alpha: sortState.name === col ? !sortState.alpha : true, + }) + } + > + {col} + +
{key}{data[key]}
+ + + +
diff --git a/web/ui/react-app/src/pages/flags/__snapshots__/Flags.test.tsx.snap b/web/ui/react-app/src/pages/flags/__snapshots__/Flags.test.tsx.snap index ae3825986..487205ea2 100644 --- a/web/ui/react-app/src/pages/flags/__snapshots__/Flags.test.tsx.snap +++ b/web/ui/react-app/src/pages/flags/__snapshots__/Flags.test.tsx.snap @@ -5,389 +5,1112 @@ exports[`Flags should match snapshot 1`] = `

Command-Line Flags

+ + + + + + + + + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - - - - + - - + - - + - - + - - + - - + + + + +
+ + Flag + + + + + Value + + +
- alertmanager.notification-queue-capacity - - 10000 + + + +
- alertmanager.timeout - - 10s + + + +
- config.file - - ./documentation/examples/prometheus.yml + + + +
- log.format - - logfmt + + + +
- log.level - - info + + + +
- query.lookback-delta - - 5m + + + +
- query.max-concurrency - - 20 + + + +
- query.max-samples - - 50000000 + + + +
- query.timeout - - 2m + + + +
- rules.alert.for-grace-period - - 10m + + + +
- rules.alert.for-outage-tolerance - - 1h + + + +
- rules.alert.resend-delay - - 1m + + + +
- storage.remote.flush-deadline - - 1m + + + +
- storage.remote.read-concurrent-limit - - 10 + + + +
- storage.remote.read-max-bytes-in-frame - - 1048576 + + + +
- storage.remote.read-sample-limit - - 50000000 + + + +
- storage.tsdb.allow-overlapping-blocks - - false + + + +
- storage.tsdb.max-block-duration - - 36h + + + +
- storage.tsdb.min-block-duration - - 2h + + + +
- storage.tsdb.no-lockfile - - false + + + +
- storage.tsdb.path - - data/ + + + +
- storage.tsdb.retention - - 0s + + + +
- storage.tsdb.retention.size - - 0B + + + +
- storage.tsdb.retention.time - - 0s + + + +
- storage.tsdb.wal-compression - - false + + + +
- storage.tsdb.wal-segment-size - - 0B + + + +
- web.console.libraries - - console_libraries + + + +
- web.console.templates - - consoles + + + +
- web.cors.origin - - .* + + + +
- web.enable-admin-api - - false + + + +
- web.enable-lifecycle - - false + + + +
- web.external-url - -
- web.listen-address - - 0.0.0.0:9090 + + + +
- web.max-connections - - 512 + + + +
- web.page-title - - Prometheus Time Series Collection and Processing Server + + + +
- web.read-timeout - - 5m + + + +
- web.route-prefix - - / + + + +
- web.user-assets - + + + + +
+ + + +
diff --git a/web/ui/react-app/src/pages/graph/ExpressionInput.tsx b/web/ui/react-app/src/pages/graph/ExpressionInput.tsx index 38200b650..bc6f52293 100644 --- a/web/ui/react-app/src/pages/graph/ExpressionInput.tsx +++ b/web/ui/react-app/src/pages/graph/ExpressionInput.tsx @@ -2,12 +2,12 @@ import React, { Component } from 'react'; import { Button, InputGroup, InputGroupAddon, InputGroupText, Input } from 'reactstrap'; import Downshift, { ControllerStateAndHelpers } from 'downshift'; -import fuzzy from 'fuzzy'; import sanitizeHTML from 'sanitize-html'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons'; import MetricsExplorer from './MetricsExplorer'; +import { Fuzzy, FuzzyResult } from '@nexucis/fuzzy'; interface ExpressionInputProps { value: string; @@ -24,6 +24,8 @@ interface ExpressionInputState { showMetricsExplorer: boolean; } +const fuz = new Fuzzy({ pre: '', post: '', shouldSort: true }); + class ExpressionInput extends Component { private exprInputRef = React.createRef(); @@ -71,10 +73,7 @@ class ExpressionInput extends Component { - return fuzzy.filter(input.replace(/ /g, ''), expressions, { - pre: '', - post: '', - }); + return fuz.filter(input.replace(/ /g, ''), expressions); }; createAutocompleteSection = (downshift: ControllerStateAndHelpers) => { @@ -96,11 +95,11 @@ class ExpressionInput extends Component{title} {matches .slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is sloooow. - .map(({ original, string: text }) => { + .map((result: FuzzyResult) => { const itemProps = downshift.getItemProps({ - key: original, + key: result.original, index, - item: original, + item: result.original, style: { backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white', }, @@ -109,7 +108,7 @@ class ExpressionInput extends Component ); })} diff --git a/web/ui/react-app/yarn.lock b/web/ui/react-app/yarn.lock index 2f5254662..27cd9da38 100644 --- a/web/ui/react-app/yarn.lock +++ b/web/ui/react-app/yarn.lock @@ -1581,6 +1581,11 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@nexucis/fuzzy@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@nexucis/fuzzy/-/fuzzy-0.2.2.tgz#60b2bd611e50a82170634027bd52751042622bea" + integrity sha512-XcBAj4bePw7rvQB86AOCnDCKAkm5JkVZh4JyVFlo4XXC/yuI4u2oTr4IQZLBsqrhI4ekZwVy3kwmO5kQ02NZzA== + "@nodelib/fs.stat@^1.1.2": version "1.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" @@ -5631,11 +5636,6 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21" integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA== -fuzzy@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/fuzzy/-/fuzzy-0.1.3.tgz#4c76ec2ff0ac1a36a9dccf9a00df8623078d4ed8" - integrity sha1-THbsL/CsGjap3M+aAN+GIweNTtg= - gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"