diff --git a/web/vtadmin/src/api/http.ts b/web/vtadmin/src/api/http.ts index ee370e73462..84117a02c07 100644 --- a/web/vtadmin/src/api/http.ts +++ b/web/vtadmin/src/api/http.ts @@ -25,9 +25,10 @@ interface HttpErrorResponse { ok: false; } -type HttpResponse = HttpOkResponse | HttpErrorResponse; - export const MALFORMED_HTTP_RESPONSE_ERROR = 'MalformedHttpResponseError'; + +// MalformedHttpResponseError is thrown when the JSON response envelope +// is an unexpected shape. class MalformedHttpResponseError extends Error { responseJson: object; @@ -39,6 +40,9 @@ class MalformedHttpResponseError extends Error { } export const HTTP_RESPONSE_NOT_OK_ERROR = 'HttpResponseNotOkError'; + +// HttpResponseNotOkError is throw when the `ok` is false in +// the JSON response envelope. class HttpResponseNotOkError extends Error { response: HttpErrorResponse | null; @@ -57,7 +61,7 @@ class HttpResponseNotOkError extends Error { // // Note that this only validates the HttpResponse envelope; it does not // do any type checking or validation on the result. -export const vtfetch = async (endpoint: string): Promise => { +export const vtfetch = async (endpoint: string): Promise => { const { REACT_APP_VTADMIN_API_ADDRESS } = process.env; const url = `${REACT_APP_VTADMIN_API_ADDRESS}${endpoint}`; @@ -68,7 +72,11 @@ export const vtfetch = async (endpoint: string): Promise => { const json = await response.json(); if (!('ok' in json)) throw new MalformedHttpResponseError('invalid http envelope', json); - return json as HttpResponse; + // Throw "not ok" responses so that react-query correctly interprets them as errors. + // See https://react-query.tanstack.com/guides/query-functions#handling-and-throwing-errors + if (!json.ok) throw new HttpResponseNotOkError(endpoint, json); + + return json as HttpOkResponse; }; export const vtfetchOpts = (): RequestInit => { @@ -96,10 +104,6 @@ export const vtfetchEntities = async (opts: { }): Promise => { const res = await vtfetch(opts.endpoint); - // Throw "not ok" responses so that react-query correctly interprets them as errors. - // See https://react-query.tanstack.com/guides/query-functions#handling-and-throwing-errors - if (!res.ok) throw new HttpResponseNotOkError(opts.endpoint, res); - const entities = opts.extract(res); if (!Array.isArray(entities)) { throw Error(`expected entities to be an array, got ${entities}`); @@ -152,6 +156,21 @@ export const fetchSchemas = async () => }, }); +export interface FetchSchemaParams { + clusterID: string; + keyspace: string; + table: string; +} + +export const fetchSchema = async ({ clusterID, keyspace, table }: FetchSchemaParams) => { + const { result } = await vtfetch(`/api/schema/${clusterID}/${keyspace}/${table}`); + + const err = pb.Schema.verify(result); + if (err) throw Error(err); + + return pb.Schema.create(result); +}; + export const fetchTablets = async () => vtfetchEntities({ endpoint: '/api/tablets', diff --git a/web/vtadmin/src/components/App.tsx b/web/vtadmin/src/components/App.tsx index c2141f9c2e8..dbb3d868a86 100644 --- a/web/vtadmin/src/components/App.tsx +++ b/web/vtadmin/src/components/App.tsx @@ -25,6 +25,7 @@ import { Clusters } from './routes/Clusters'; import { Gates } from './routes/Gates'; import { Keyspaces } from './routes/Keyspaces'; import { Schemas } from './routes/Schemas'; +import { Schema } from './routes/Schema'; export const App = () => { return ( @@ -52,6 +53,10 @@ export const App = () => { + + + + diff --git a/web/vtadmin/src/components/Code.module.scss b/web/vtadmin/src/components/Code.module.scss new file mode 100644 index 00000000000..772c6647eec --- /dev/null +++ b/web/vtadmin/src/components/Code.module.scss @@ -0,0 +1,56 @@ +/** + * 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. + */ +.table { + margin: 8px 0; +} + +.table tr { + border: none; +} + +.table td { + border: none; + line-height: 1.6; + margin: 0; +} + +.lineNumber { + box-sizing: border-box; + color: var(--textColorSecondary); + font-family: var(--fontFamilyMonospace); + font-size: var(--fontSizeDefault); + line-height: 2.4rem; + min-width: 5rem; + padding: 0 1.2rem; + text-align: right; + user-select: none; + vertical-align: top; + white-space: nowrap; + width: 1%; +} + +.lineNumber::before { + content: attr(data-line-number); +} + +.code { + font-family: var(--fontFamilyMonospace); + font-size: var(--fontSizeDefault); + line-height: 2.4rem; + padding: 0 1.2rem; + tab-size: 8; + white-space: pre; +} diff --git a/web/vtadmin/src/components/Code.tsx b/web/vtadmin/src/components/Code.tsx new file mode 100644 index 00000000000..d547bfbf32d --- /dev/null +++ b/web/vtadmin/src/components/Code.tsx @@ -0,0 +1,44 @@ +/** + * 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 React from 'react'; + +import style from './Code.module.scss'; + +interface Props { + code?: string | null | undefined; +} + +export const Code = ({ code }: Props) => { + if (typeof code !== 'string') return null; + + const codeLines = code.split('\n'); + return ( + + + {codeLines.map((line, idx) => { + return ( + + + + ); + })} + +
+ + {line} +
+ ); +}; diff --git a/web/vtadmin/src/components/routes/Schema.module.scss b/web/vtadmin/src/components/routes/Schema.module.scss new file mode 100644 index 00000000000..0e1595e0fe4 --- /dev/null +++ b/web/vtadmin/src/components/routes/Schema.module.scss @@ -0,0 +1,73 @@ +/** + * 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. + */ + +.header { + margin: 4.8rem 0; +} + +.header h1 { + margin: 2.4rem 0 0 0; + padding: 0; +} + +.headingMeta { + display: flex; +} + +.headingMeta span { + display: inline-block; + font-size: var(--fontSizeLarge); + line-height: 2; + + &::after { + color: var(--colorScaffoldingHighlight); + content: '/'; + display: inline-block; + margin: 0 1.2rem; + } + + &:last-child::after { + content: none; + } +} + +.panel { + border: solid 1px var(--colorScaffoldingHighlight); + border-radius: 6px; + padding: 0 2.4rem 2.4rem 2.4rem; + max-width: 960px; + overflow: auto; +} + +.errorPlaceholder { + align-items: center; + display: flex; + flex-direction: column; + font-size: var(--fontSizeLarge); + justify-content: center; + margin: 25vh auto; + max-width: 720px; + text-align: center; + + h1 { + margin: 1.6rem 0; + } +} + +.errorEmoji { + display: block; + font-size: 5.6rem; +} diff --git a/web/vtadmin/src/components/routes/Schema.tsx b/web/vtadmin/src/components/routes/Schema.tsx new file mode 100644 index 00000000000..c943452964f --- /dev/null +++ b/web/vtadmin/src/components/routes/Schema.tsx @@ -0,0 +1,105 @@ +/** + * 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 React from 'react'; +import { Link, useParams } from 'react-router-dom'; + +import style from './Schema.module.scss'; +import { useSchema } from '../../hooks/api'; +import { Code } from '../Code'; +import { useDocumentTitle } from '../../hooks/useDocumentTitle'; + +interface RouteParams { + clusterID: string; + keyspace: string; + table: string; +} + +export const Schema = () => { + const { clusterID, keyspace, table } = useParams(); + const { data, error, isError, isLoading, isSuccess } = useSchema({ clusterID, keyspace, table }); + + useDocumentTitle(`${table} (${keyspace})`); + + const tableDefinition = React.useMemo( + () => + data && Array.isArray(data.table_definitions) + ? data?.table_definitions.find((t) => t.name === table) + : null, + [data, table] + ); + + const is404 = isSuccess && !tableDefinition; + + return ( +
+ {!is404 && !isError && ( +
+

+ ← All schemas +

+ +

{table}

+
+
+ + Cluster: {clusterID} + + + Keyspace: {keyspace} + +
+
+ )} + + {/* TODO: skeleton placeholder */} + {isLoading &&
Loading...
} + + {isError && ( +
+ 😰 +

An error occurred

+ {(error as any).response?.error?.message || error?.message} +

+ ← All schemas +

+
+ )} + + {is404 && ( +
+ 😖 +

Schema not found

+

+ No schema found with table {table} in keyspace {keyspace} (cluster{' '} + {clusterID}). +

+

+ ← All schemas +

+
+ )} + + {!is404 && !isError && tableDefinition && ( +
+
+

Table Definition

+ +
+
+ )} +
+ ); +}; diff --git a/web/vtadmin/src/components/routes/Schemas.tsx b/web/vtadmin/src/components/routes/Schemas.tsx index abd34c8a9cf..c7054510432 100644 --- a/web/vtadmin/src/components/routes/Schemas.tsx +++ b/web/vtadmin/src/components/routes/Schemas.tsx @@ -15,6 +15,7 @@ */ import { orderBy } from 'lodash-es'; import * as React from 'react'; +import { Link } from 'react-router-dom'; import { TableDefinition, useTableDefinitions } from '../../hooks/api'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; import { DataTable } from '../dataTable/DataTable'; @@ -28,13 +29,19 @@ export const Schemas = () => { }, [data]); const renderRows = (rows: TableDefinition[]) => - rows.map((row, idx) => ( - - {row.cluster?.name} - {row.keyspace} - {row.tableDefinition?.name} - - )); + rows.map((row, idx) => { + const href = + row.cluster?.id && row.keyspace && row.tableDefinition?.name + ? `/schema/${row.cluster.id}/${row.keyspace}/${row.tableDefinition.name}` + : null; + return ( + + {row.cluster?.name} + {row.keyspace} + {href ? {row.tableDefinition?.name} : row.tableDefinition?.name} + + ); + }); return (
diff --git a/web/vtadmin/src/hooks/api.ts b/web/vtadmin/src/hooks/api.ts index 34bc7a99f3b..606a808d1d9 100644 --- a/web/vtadmin/src/hooks/api.ts +++ b/web/vtadmin/src/hooks/api.ts @@ -1,5 +1,13 @@ -import { useQuery } from 'react-query'; -import { fetchClusters, fetchGates, fetchKeyspaces, fetchSchemas, fetchTablets } from '../api/http'; +import { useQuery, useQueryClient } from 'react-query'; +import { + fetchClusters, + fetchGates, + fetchKeyspaces, + fetchSchema, + FetchSchemaParams, + fetchSchemas, + fetchTablets, +} from '../api/http'; import { vtadmin as pb } from '../proto/vtadmin'; export const useClusters = () => useQuery(['clusters'], fetchClusters); @@ -43,3 +51,18 @@ export const useTableDefinitions = () => { return { ...query, data: tds }; }; + +export const useSchema = (params: FetchSchemaParams) => { + const queryClient = useQueryClient(); + return useQuery(['schema', params], () => fetchSchema(params), { + initialData: () => { + const schemas = queryClient.getQueryData('schemas'); + return (schemas || []).find( + (s: pb.Schema) => + s.cluster?.id === params.clusterID && + s.keyspace === params.keyspace && + s.table_definitions.find((td) => td.name === params.table) + ); + }, + }); +}; diff --git a/web/vtadmin/src/index.css b/web/vtadmin/src/index.css index 44f67001e86..14514c9da6e 100644 --- a/web/vtadmin/src/index.css +++ b/web/vtadmin/src/index.css @@ -154,8 +154,19 @@ h3 { } code { + display: inline-block; font-family: var(--fontFamilyMonospace); - font-size: var(--fontSizeDefault); + margin: 0 2px; +} + +/* Links */ +a, +a:visited, +a:focus, +a:active { + color: var(--colorPrimary); + cursor: pointer; + text-decoration: none; } /* Tables */