From c0b96449c0f2aa51910458587e69a6202d5510ef Mon Sep 17 00:00:00 2001 From: Sara Bee <855595+doeg@users.noreply.github.com> Date: Fri, 30 Apr 2021 13:18:25 -0400 Subject: [PATCH] [vtadmin-web] Display timestamps + stream counts on Workflows view Signed-off-by: Sara Bee <855595+doeg@users.noreply.github.com> --- web/vtadmin/package-lock.json | 5 ++ web/vtadmin/package.json | 1 + .../src/components/pips/StreamStatePip.tsx | 33 +++++++++ .../components/routes/Workflows.module.scss | 7 ++ .../src/components/routes/Workflows.tsx | 61 +++++++++++----- web/vtadmin/src/index.css | 4 ++ web/vtadmin/src/util/time.ts | 42 +++++++++++ web/vtadmin/src/util/workflows.test.ts | 72 +++++++++++++++++++ web/vtadmin/src/util/workflows.ts | 43 +++++++++++ 9 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 web/vtadmin/src/components/pips/StreamStatePip.tsx create mode 100644 web/vtadmin/src/util/time.ts create mode 100644 web/vtadmin/src/util/workflows.test.ts create mode 100644 web/vtadmin/src/util/workflows.ts diff --git a/web/vtadmin/package-lock.json b/web/vtadmin/package-lock.json index 3e44e8a070c..375bec857a8 100644 --- a/web/vtadmin/package-lock.json +++ b/web/vtadmin/package-lock.json @@ -5133,6 +5133,11 @@ "whatwg-url": "^8.0.0" } }, + "dayjs": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz", + "integrity": "sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==" + }, "debug": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", diff --git a/web/vtadmin/package.json b/web/vtadmin/package.json index 12ec97abd53..bb0b453e730 100644 --- a/web/vtadmin/package.json +++ b/web/vtadmin/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "^16.9.10", "@types/react-router-dom": "^5.1.7", "classnames": "^2.2.6", + "dayjs": "^1.10.4", "downshift": "^6.1.0", "history": "^5.0.0", "lodash-es": "^4.17.20", diff --git a/web/vtadmin/src/components/pips/StreamStatePip.tsx b/web/vtadmin/src/components/pips/StreamStatePip.tsx new file mode 100644 index 00000000000..e68d358b654 --- /dev/null +++ b/web/vtadmin/src/components/pips/StreamStatePip.tsx @@ -0,0 +1,33 @@ +/** + * 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 { Pip, PipState } from './Pip'; + +interface Props { + state?: string | null | undefined; +} + +// TODO(doeg): add a protobuf enum for this (https://github.com/vitessio/vitess/projects/12#card-60190340) +const STREAM_STATES: { [key: string]: PipState } = { + Copying: 'primary', + Error: 'danger', + Running: 'success', + Stopped: null, +}; + +export const StreamStatePip = ({ state }: Props) => { + const pipState = state ? STREAM_STATES[state] : null; + return ; +}; diff --git a/web/vtadmin/src/components/routes/Workflows.module.scss b/web/vtadmin/src/components/routes/Workflows.module.scss index c6cd7194d4b..aa05570c364 100644 --- a/web/vtadmin/src/components/routes/Workflows.module.scss +++ b/web/vtadmin/src/components/routes/Workflows.module.scss @@ -19,3 +19,10 @@ grid-template-columns: 1fr min-content; margin-bottom: 24px; } + +.shardList { + color: var(--textColorSecondary); + font-size: var(--fontSizeSmall); + max-width: 20rem; + padding-right: 24px; +} diff --git a/web/vtadmin/src/components/routes/Workflows.tsx b/web/vtadmin/src/components/routes/Workflows.tsx index 14777459f05..cc6beb8514a 100644 --- a/web/vtadmin/src/components/routes/Workflows.tsx +++ b/web/vtadmin/src/components/routes/Workflows.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { orderBy } from 'lodash-es'; +import { groupBy, orderBy } from 'lodash-es'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -27,22 +27,27 @@ import { Icons } from '../Icon'; import { TextInput } from '../TextInput'; import { useSyncedURLParam } from '../../hooks/useSyncedURLParam'; import { filterNouns } from '../../util/filterNouns'; +import { getStreams, getTimeUpdated } from '../../util/workflows'; +import { formatDateTime, formatRelativeTime } from '../../util/time'; +import { StreamStatePip } from '../pips/StreamStatePip'; export const Workflows = () => { useDocumentTitle('Workflows'); - const { data } = useWorkflows(); + const { data } = useWorkflows({ refetchInterval: 1000 }); const { value: filter, updateValue: updateFilter } = useSyncedURLParam('filter'); const sortedData = React.useMemo(() => { - const mapped = (data || []).map(({ cluster, keyspace, workflow }) => ({ - clusterID: cluster?.id, - clusterName: cluster?.name, - keyspace, - name: workflow?.name, - source: workflow?.source?.keyspace, - sourceShards: workflow?.source?.shards, - target: workflow?.target?.keyspace, - targetShards: workflow?.target?.shards, + const mapped = (data || []).map((workflow) => ({ + clusterID: workflow.cluster?.id, + clusterName: workflow.cluster?.name, + keyspace: workflow.keyspace, + name: workflow.workflow?.name, + source: workflow.workflow?.source?.keyspace, + sourceShards: workflow.workflow?.source?.shards, + streams: groupBy(getStreams(workflow), 'state'), + target: workflow.workflow?.target?.keyspace, + targetShards: workflow.workflow?.target?.shards, + timeUpdated: getTimeUpdated(workflow), })); const filtered = filterNouns(filter, mapped); return orderBy(filtered, ['name', 'clusterName', 'source', 'target']); @@ -65,9 +70,7 @@ export const Workflows = () => { {row.source ? ( <>
{row.source}
-
- {(row.sourceShards || []).join(', ')} -
+
{(row.sourceShards || []).join(', ')}
) : ( N/A @@ -77,14 +80,32 @@ export const Workflows = () => { {row.target ? ( <>
{row.target}
-
- {(row.targetShards || []).join(', ')} -
+
{(row.targetShards || []).join(', ')}
) : ( N/A )} + + {/* TODO(doeg): add a protobuf enum for this (https://github.com/vitessio/vitess/projects/12#card-60190340) */} + {['Error', 'Copying', 'Running', 'Stopped'].map((streamState) => ( + + {streamState in row.streams ? ( + <> + {row.streams[streamState].length} + + ) : ( + - + )} + + ))} + + +
{formatDateTime(row.timeUpdated)}
+
+ {formatRelativeTime(row.timeUpdated)} +
+
); }); @@ -106,7 +127,11 @@ export const Workflows = () => { - + ); }; diff --git a/web/vtadmin/src/index.css b/web/vtadmin/src/index.css index 52c453b0a4d..ff81e4dd254 100644 --- a/web/vtadmin/src/index.css +++ b/web/vtadmin/src/index.css @@ -203,6 +203,10 @@ table tbody td[rowSpan] { font-family: var(--fontFamilyMonospace); } +.font-family-primary { + font-family: var(--fontFamilyPrimary); +} + .font-size-small { font-size: var(--fontSizeSmall); } diff --git a/web/vtadmin/src/util/time.ts b/web/vtadmin/src/util/time.ts new file mode 100644 index 00000000000..ab51e47fb5c --- /dev/null +++ b/web/vtadmin/src/util/time.ts @@ -0,0 +1,42 @@ +/** + * 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 * as dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(localizedFormat); +dayjs.extend(relativeTime); + +export const parse = (timestamp: number | null | undefined): dayjs.Dayjs | null => { + if (typeof timestamp !== 'number') { + return null; + } + return dayjs.unix(timestamp); +}; + +export const format = (timestamp: number | null | undefined, template: string | undefined): string | null => { + const u = parse(timestamp); + return u ? u.format(template) : null; +}; + +export const formatDateTime = (timestamp: number | null | undefined): string | null => { + return format(timestamp, 'YYYY-MM-DD LT'); +}; + +export const formatRelativeTime = (timestamp: number | null | undefined): string | null => { + const u = parse(timestamp); + return u ? u.fromNow() : null; +}; diff --git a/web/vtadmin/src/util/workflows.test.ts b/web/vtadmin/src/util/workflows.test.ts new file mode 100644 index 00000000000..38dc0e31987 --- /dev/null +++ b/web/vtadmin/src/util/workflows.test.ts @@ -0,0 +1,72 @@ +/** + * 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 } from '../proto/vtadmin'; +import { getStreams } from './workflows'; + +describe('getStreams', () => { + const tests: { + name: string; + input: Parameters; + expected: ReturnType; + }[] = [ + { + name: 'should return a flat list of streams', + input: [ + pb.Workflow.create({ + workflow: { + shard_streams: { + '-80/us_east_1a-123456': { + streams: [ + { id: 1, shard: '-80' }, + { id: 2, shard: '-80' }, + ], + }, + '80-/us_east_1a-789012': { + streams: [ + { id: 1, shard: '80-' }, + { id: 2, shard: '80-' }, + ], + }, + }, + }, + }), + ], + expected: [ + { id: 1, shard: '-80' }, + { id: 2, shard: '-80' }, + { id: 1, shard: '80-' }, + { id: 2, shard: '80-' }, + ], + }, + { + name: 'should handle when shard streams undefined', + input: [pb.Workflow.create()], + expected: [], + }, + { + name: 'should handle null input', + input: [null], + expected: [], + }, + ]; + + test.each(tests.map(Object.values))( + '%s', + (name: string, input: Parameters, expected: ReturnType) => { + expect(getStreams(...input)).toEqual(expected); + } + ); +}); diff --git a/web/vtadmin/src/util/workflows.ts b/web/vtadmin/src/util/workflows.ts new file mode 100644 index 00000000000..99b85b07efa --- /dev/null +++ b/web/vtadmin/src/util/workflows.ts @@ -0,0 +1,43 @@ +/** + * 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 { vtctldata, vtadmin as pb } from '../proto/vtadmin'; + +/** + * getStreams returns a flat list of streams across all keyspaces/shards in the workflow. + */ +export const getStreams = (workflow: W | null | undefined): vtctldata.Workflow.IStream[] => { + if (!workflow) { + return []; + } + + return Object.values(workflow.workflow?.shard_streams || {}).reduce((acc, shardStream) => { + (shardStream.streams || []).forEach((stream) => { + acc.push(stream); + }); + return acc; + }, [] as vtctldata.Workflow.IStream[]); +}; + +/** + * getTimeUpdated returns the `time_updated` timestamp of the most recently + * updated stream in the workflow. + */ +export const getTimeUpdated = (workflow: W | null | undefined): number => { + // Note: long-term it may be better to get this from the `vreplication_log` data + // added by https://github.com/vitessio/vitess/pull/7831 + const timestamps = getStreams(workflow).map((s) => parseInt(`${s.time_updated?.seconds}`, 10)); + return Math.max(...timestamps); +};