Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions web/vtadmin/src/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -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<HttpResponse> => {
export const vtfetch = async (endpoint: string): Promise<HttpOkResponse> => {
const { REACT_APP_VTADMIN_API_ADDRESS } = process.env;

const url = `${REACT_APP_VTADMIN_API_ADDRESS}${endpoint}`;
Expand All @@ -68,7 +72,11 @@ export const vtfetch = async (endpoint: string): Promise<HttpResponse> => {
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"NotOkError" seems redundant to me, how about either throw HttpResponseNotOk or throw HttpResponseError ? (also if this is "just a react/ts convention" please let me know!)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also i know this is a type from previous PRs ....... which i probably approved without comment so .........)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's redundant but I do find it descriptive. This error is specifically when response.ok (in the JSON response envelope) is false.

There are other kinds of "HTTP response errors", like MalformedHttpResponseError which occurs when the response envelope is an unexpected shape. (In all cases, the Error suffix is there since it extends the base Error class.)

I added a commit with bit of commentary on the above. If you still feel like HttpResponseNotOkError is redundant, I can change it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, this makes sense to me!


return json as HttpOkResponse;
};

export const vtfetchOpts = (): RequestInit => {
Expand Down Expand Up @@ -96,10 +104,6 @@ export const vtfetchEntities = async <T>(opts: {
}): Promise<T[]> => {
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}`);
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions web/vtadmin/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -52,6 +53,10 @@ export const App = () => {
<Schemas />
</Route>

<Route path="/schema/:clusterID/:keyspace/:table">
<Schema />
</Route>

<Route path="/tablets">
<Tablets />
</Route>
Expand Down
56 changes: 56 additions & 0 deletions web/vtadmin/src/components/Code.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions web/vtadmin/src/components/Code.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<table className={style.table}>
<tbody>
{codeLines.map((line, idx) => {
return (
<tr key={idx}>
<td id={`L${idx}`} className={style.lineNumber} data-line-number={idx} />
<td className={style.code}>
<code>{line}</code>
</td>
</tr>
);
})}
</tbody>
</table>
);
};
73 changes: 73 additions & 0 deletions web/vtadmin/src/components/routes/Schema.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
105 changes: 105 additions & 0 deletions web/vtadmin/src/components/routes/Schema.tsx
Original file line number Diff line number Diff line change
@@ -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<RouteParams>();
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 (
<div>
{!is404 && !isError && (
<header className={style.header}>
<p>
<Link to="/schemas">← All schemas</Link>
</p>
<code>
<h1>{table}</h1>
</code>
<div className={style.headingMeta}>
<span>
Cluster: <code>{clusterID}</code>
</span>
<span>
Keyspace: <code>{keyspace}</code>
</span>
</div>
</header>
)}

{/* TODO: skeleton placeholder */}
{isLoading && <div className={style.loadingPlaceholder}>Loading...</div>}

{isError && (
<div className={style.errorPlaceholder}>
<span className={style.errorEmoji}>😰</span>
<h1>An error occurred</h1>
<code>{(error as any).response?.error?.message || error?.message}</code>
<p>
<Link to="/schemas">← All schemas</Link>
</p>
</div>
)}

{is404 && (
<div className={style.errorPlaceholder}>
<span className={style.errorEmoji}>😖</span>
<h1>Schema not found</h1>
<p>
No schema found with table <code>{table}</code> in keyspace <code>{keyspace}</code> (cluster{' '}
<code>{clusterID}</code>).
</p>
<p>
<Link to="/schemas">← All schemas</Link>
</p>
</div>
)}

{!is404 && !isError && tableDefinition && (
<div className={style.container}>
<section className={style.panel}>
<h3>Table Definition</h3>
<Code code={tableDefinition.schema} />
</section>
</div>
)}
</div>
);
};
Loading