diff --git a/web/vtadmin/src/components/routes/Tablets.test.tsx b/web/vtadmin/src/components/routes/Tablets.test.tsx new file mode 100644 index 00000000000..9beb1bef970 --- /dev/null +++ b/web/vtadmin/src/components/routes/Tablets.test.tsx @@ -0,0 +1,94 @@ +/** + * Copyright 2021 The Vitess Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { vtadmin as pb, topodata } from '../../proto/vtadmin'; +import { formatRows } from './Tablets'; + +describe('Tablets', () => { + describe('filterRows', () => { + const tests: { + name: string; + filter: string | null; + tablets: pb.Tablet[] | null; + // Undefined `expected` keys will be ignored. + // Unfortunately, Partial> does not + // work as expected + requires all property keys to be defined. + expected: { [k: string]: unknown }[]; + }[] = [ + { + name: 'empty tablets', + filter: null, + tablets: null, + expected: [], + }, + { + name: 'sort by primary first, then other tablet types alphabetically', + filter: null, + tablets: [ + pb.Tablet.create({ + tablet: { + alias: { + cell: 'cell1', + uid: 4, + }, + type: topodata.TabletType.BACKUP, + }, + }), + pb.Tablet.create({ + tablet: { + alias: { + cell: 'cell1', + uid: 2, + }, + type: topodata.TabletType.REPLICA, + }, + }), + pb.Tablet.create({ + tablet: { + alias: { + cell: 'cell1', + uid: 3, + }, + type: topodata.TabletType.MASTER, + }, + }), + pb.Tablet.create({ + tablet: { + alias: { + cell: 'cell1', + uid: 1, + }, + type: topodata.TabletType.REPLICA, + }, + }), + ], + expected: [ + { alias: 'cell1-3', type: 'PRIMARY' }, + { alias: 'cell1-4', type: 'BACKUP' }, + { alias: 'cell1-1', type: 'REPLICA' }, + { alias: 'cell1-2', type: 'REPLICA' }, + ], + }, + ]; + + test.each(tests.map(Object.values))( + '%s', + (name: string, filter: string, tablets: pb.Tablet[], expected: { [k: string]: unknown }[]) => { + const result = formatRows(tablets, filter); + expect(result).toMatchObject(expected); + } + ); + }); +}); diff --git a/web/vtadmin/src/components/routes/Tablets.tsx b/web/vtadmin/src/components/routes/Tablets.tsx index 0e99e553a91..2a0ddd3c684 100644 --- a/web/vtadmin/src/components/routes/Tablets.tsx +++ b/web/vtadmin/src/components/routes/Tablets.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import { useTablets } from '../../hooks/api'; import { vtadmin as pb, topodata } from '../../proto/vtadmin'; -import { orderBy } from 'lodash-es'; +import { invertBy, orderBy } from 'lodash-es'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; import { DataTable } from '../dataTable/DataTable'; import { TextInput } from '../TextInput'; @@ -33,26 +33,8 @@ export const Tablets = () => { const { data = [] } = useTablets(); const filteredData = React.useMemo(() => { - // Properties prefixed with "_" are hidden and included for filtering only. - // They also won't work as keys in key:value searches, e.g., you cannot - // search for `_keyspaceShard:customers/20-40`, by design, mostly because it's - // unexpected and a little weird to key on properties that you can't see. - const mapped = data.map((t) => ({ - cluster: t.cluster?.name, - keyspace: t.tablet?.keyspace, - shard: t.tablet?.shard, - alias: formatAlias(t), - hostname: t.tablet?.hostname, - displayType: formatDisplayType(t), - state: formatState(t), - _keyspaceShard: `${t.tablet?.keyspace}/${t.tablet?.shard}`, - // Include the unformatted type so (string) filtering by "master" works - // even if "primary" is what we display, and what we use for key:value searches. - _type: formatType(t), - })); - const filtered = filterNouns(filter, mapped); - return orderBy(filtered, ['cluster', 'keyspace', 'shard', 'displayType']); - }, [filter, data]); + return formatRows(data, filter); + }, [data, filter]); const renderRows = React.useCallback((rows: typeof filteredData) => { return rows.map((t, tdx) => ( @@ -60,7 +42,7 @@ export const Tablets = () => { {t.cluster} {t.keyspace} {t.shard} - {t.displayType} + {t.type} {t.state} {t.alias} {t.hostname} @@ -93,7 +75,16 @@ export const Tablets = () => { }; const SERVING_STATES = Object.keys(pb.Tablet.ServingState); -const TABLET_TYPES = Object.keys(topodata.TabletType); + +// TABLET_TYPES maps numeric tablet types back to human readable strings. +// Note that topodata.TabletType allows duplicate values: specifically, +// both RDONLY (new name) and BATCH (old name) share the same numeric value. +// So, we make the assumption that if there are duplicate keys, we will +// always take the first value. +const TABLET_TYPES = Object.entries(invertBy(topodata.TabletType)).reduce((acc, [k, vs]) => { + acc[k] = vs[0]; + return acc; +}, {} as { [k: string]: string }); const formatAlias = (t: pb.Tablet) => t.tablet?.alias?.cell && t.tablet?.alias?.uid && `${t.tablet.alias.cell}-${t.tablet.alias.uid}`; @@ -108,3 +99,29 @@ const formatDisplayType = (t: pb.Tablet) => { }; const formatState = (t: pb.Tablet) => t.state && SERVING_STATES[t.state]; + +export const formatRows = (tablets: pb.Tablet[] | null, filter: string) => { + if (!tablets) return []; + + // Properties prefixed with "_" are hidden and included for filtering only. + // They also won't work as keys in key:value searches, e.g., you cannot + // search for `_keyspaceShard:customers/20-40`, by design, mostly because it's + // unexpected and a little weird to key on properties that you can't see. + const mapped = tablets.map((t) => ({ + cluster: t.cluster?.name, + keyspace: t.tablet?.keyspace, + shard: t.tablet?.shard, + alias: formatAlias(t), + hostname: t.tablet?.hostname, + type: formatDisplayType(t), + state: formatState(t), + _keyspaceShard: `${t.tablet?.keyspace}/${t.tablet?.shard}`, + // Include the unformatted type so (string) filtering by "master" works + // even if "primary" is what we display, and what we use for key:value searches. + _rawType: formatType(t), + // Always sort primary tablets first, then sort alphabetically by type, etc. + _typeSortOrder: formatDisplayType(t) === 'PRIMARY' ? 1 : 2, + })); + const filtered = filterNouns(filter, mapped); + return orderBy(filtered, ['cluster', 'keyspace', 'shard', '_typeSortOrder', 'type', 'alias']); +}; diff --git a/web/vtadmin/src/util/filterNouns.ts b/web/vtadmin/src/util/filterNouns.ts index c8f2bc5310e..d20ec43f492 100644 --- a/web/vtadmin/src/util/filterNouns.ts +++ b/web/vtadmin/src/util/filterNouns.ts @@ -20,7 +20,7 @@ import { KeyValueSearchToken, SearchTokenTypes, tokenizeSearch } from './tokeniz /** * `filterNouns` filters a list of nouns by a search string. */ -export const filterNouns = (needle: string, haystack: T[]): T[] => { +export const filterNouns = (needle: string | null, haystack: T[]): T[] => { if (!needle) return haystack; const tokens = tokenizeSearch(needle);