Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[vtadmin-web] Add routes and simple tables for all entities #7440

Merged
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
20 changes: 20 additions & 0 deletions web/vtadmin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/vtadmin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@types/react-dom": "^16.9.10",
"@types/react-router-dom": "^5.1.7",
"classnames": "^2.2.6",
"lodash-es": "^4.17.20",
"node-sass": "^4.14.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
Expand Down Expand Up @@ -59,6 +60,7 @@
]
},
"devDependencies": {
"@types/lodash-es": "^4.17.4",
"msw": "^0.24.4",
"prettier": "^2.2.1",
"protobufjs": "^6.10.2",
Expand Down
81 changes: 21 additions & 60 deletions web/vtadmin/src/api/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,22 @@ describe('api/http', () => {
expect(result).toEqual(response);
});

it('parses and returns JSON, given an HttpErrorResponse response', async () => {
it('throws an error if response.ok is false', async () => {
const endpoint = `/api/tablets`;
const response = { ok: false };
mockServerJson(endpoint, response);

const result = await api.vtfetch(endpoint);
expect(result).toEqual(response);
expect.assertions(3);

try {
await api.fetchTablets();
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
expect(e.name).toEqual(HTTP_RESPONSE_NOT_OK_ERROR);
expect(e.message).toEqual(endpoint);
expect(e.response).toEqual(response);
/* eslint-enable jest/no-conditional-expect */
}
});

it('throws an error on malformed JSON', async () => {
Expand Down Expand Up @@ -186,70 +195,22 @@ describe('api/http', () => {
});
});

describe('fetchTablets', () => {
it('returns a list of Tablets, given a successful response', async () => {
const t0 = pb.Tablet.create({ tablet: { hostname: 't0' } });
const t1 = pb.Tablet.create({ tablet: { hostname: 't1' } });
const t2 = pb.Tablet.create({ tablet: { hostname: 't2' } });
const tablets = [t0, t1, t2];

mockServerJson(`/api/tablets`, {
ok: true,
result: {
tablets: tablets.map((t) => t.toJSON()),
},
});

const result = await api.fetchTablets();
expect(result).toEqual(tablets);
});

it('throws an error if response.ok is false', async () => {
const response = { ok: false };
mockServerJson('/api/tablets', response);

expect.assertions(3);

try {
await api.fetchTablets();
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
expect(e.name).toEqual(HTTP_RESPONSE_NOT_OK_ERROR);
expect(e.message).toEqual('/api/tablets');
expect(e.response).toEqual(response);
/* eslint-enable jest/no-conditional-expect */
}
});

describe('vtfetchEntities', () => {
it('throws an error if result.tablets is not an array', async () => {
mockServerJson('/api/tablets', { ok: true, result: { tablets: null } });

expect.assertions(1);

try {
await api.fetchTablets();
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
expect(e.message).toMatch('expected tablets to be an array');
/* eslint-enable jest/no-conditional-expect */
}
});

it('throws an error if JSON cannot be unmarshalled into Tablet objects', async () => {
mockServerJson(`/api/tablets`, {
ok: true,
result: {
tablets: [{ cluster: 'this should be an object, not a string' }],
},
});
const endpoint = '/api/foos';
mockServerJson(endpoint, { ok: true, result: { foos: null } });

expect.assertions(1);

try {
await api.fetchTablets();
await api.vtfetchEntities({
endpoint,
extract: (res) => res.result.foos,
transform: (e) => null, // doesn't matter
});
} catch (e) {
/* eslint-disable jest/no-conditional-expect */
expect(e.message).toEqual('cluster.object expected');
expect(e.message).toMatch('expected entities to be an array, got null');
/* eslint-enable jest/no-conditional-expect */
}
});
Expand Down
85 changes: 74 additions & 11 deletions web/vtadmin/src/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,84 @@ export const vtfetchOpts = (): RequestInit => {
return { credentials };
};

export const fetchTablets = async () => {
const endpoint = '/api/tablets';
const res = await vtfetch(endpoint);
// vtfetchEntities is a helper function for querying vtadmin-api endpoints
// that return a list of protobuf entities.
export const vtfetchEntities = async <T>(opts: {
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this a lot!

endpoint: string;
// Extract the list of entities from the response. We can't (strictly)
// guarantee type safety for API responses, hence the `any` return type.
extract: (res: HttpOkResponse) => any;
// Transform an individual entity in the array to its (proto)typed form.
// This will almost always be a `.verify` followed by a `.create`,
// but because of how protobufjs structures its generated types,
// writing this in a generic way is... unpleasant, and difficult to read.
transform: (e: object) => T;
}): 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(endpoint, res);
if (!res.ok) throw new HttpResponseNotOkError(opts.endpoint, res);

const tablets = res.result?.tablets;
if (!Array.isArray(tablets)) throw Error(`expected tablets to be an array, got ${tablets}`);
const entities = opts.extract(res);
if (!Array.isArray(entities)) {
throw Error(`expected entities to be an array, got ${entities}`);
}

return tablets.map((t: any) => {
const err = pb.Tablet.verify(t);
if (err) throw Error(err);
return entities.map(opts.transform);
};

return pb.Tablet.create(t);
export const fetchClusters = async () =>
vtfetchEntities({
endpoint: '/api/clusters',
extract: (res) => res.result.clusters,
transform: (e) => {
const err = pb.Cluster.verify(e);
if (err) throw Error(err);
return pb.Cluster.create(e);
},
});

export const fetchGates = async () =>
vtfetchEntities({
endpoint: '/api/gates',
extract: (res) => res.result.gates,
transform: (e) => {
const err = pb.VTGate.verify(e);
if (err) throw Error(err);
return pb.VTGate.create(e);
},
});

export const fetchKeyspaces = async () =>
vtfetchEntities({
endpoint: '/api/keyspaces',
extract: (res) => res.result.keyspaces,
transform: (e) => {
const err = pb.Keyspace.verify(e);
if (err) throw Error(err);
return pb.Keyspace.create(e);
},
});

export const fetchSchemas = async () =>
vtfetchEntities({
endpoint: '/api/schemas',
extract: (res) => res.result.schemas,
transform: (e) => {
const err = pb.Schema.verify(e);
if (err) throw Error(err);
return pb.Schema.create(e);
},
});

export const fetchTablets = async () =>
vtfetchEntities({
endpoint: '/api/tablets',
extract: (res) => res.result.tablets,
transform: (e) => {
const err = pb.Tablet.verify(e);
if (err) throw Error(err);
return pb.Tablet.create(e);
},
});
};
20 changes: 20 additions & 0 deletions web/vtadmin/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { Tablets } from './routes/Tablets';
import { Debug } from './routes/Debug';
import { NavRail } from './NavRail';
import { Error404 } from './routes/Error404';
import { Clusters } from './routes/Clusters';
import { Gates } from './routes/Gates';
import { Keyspaces } from './routes/Keyspaces';
import { Schemas } from './routes/Schemas';

export const App = () => {
return (
Expand All @@ -32,6 +36,22 @@ export const App = () => {

<div className={style.mainContainer}>
<Switch>
<Route path="/clusters">
<Clusters />
</Route>

<Route path="/gates">
<Gates />
</Route>

<Route path="/keyspaces">
<Keyspaces />
</Route>

<Route path="/schemas">
<Schemas />
</Route>

<Route path="/tablets">
<Tablets />
</Route>
Expand Down
18 changes: 11 additions & 7 deletions web/vtadmin/src/components/NavRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ import { Link, NavLink } from 'react-router-dom';

import style from './NavRail.module.scss';
import logo from '../img/vitess-icon-color.svg';
import { useTablets } from '../hooks/api';
import { useClusters, useGates, useKeyspaces, useTableDefinitions, useTablets } from '../hooks/api';
import { Icon, Icons } from './Icon';

export const NavRail = () => {
const { data: tabletData } = useTablets();
const { data: clusters = [] } = useClusters();
const { data: keyspaces = [] } = useKeyspaces();
const { data: gates = [] } = useGates();
const { data: schemas = [] } = useTableDefinitions();
const { data: tablets = [] } = useTablets();

return (
<div className={style.container}>
Expand All @@ -43,19 +47,19 @@ export const NavRail = () => {
<ul className={style.navList}>
<li>
{/* FIXME replace this with a C when we have one */}
<NavRailLink icon={Icons.keyR} text="Clusters" to="/clusters" count={0} />
<NavRailLink icon={Icons.keyR} text="Clusters" to="/clusters" count={clusters.length} />
</li>
<li>
<NavRailLink icon={Icons.keyG} text="Gates" to="/gates" count={0} />
<NavRailLink icon={Icons.keyG} text="Gates" to="/gates" count={gates.length} />
</li>
<li>
<NavRailLink icon={Icons.keyK} text="Keyspaces" to="/keyspaces" count={0} />
<NavRailLink icon={Icons.keyK} text="Keyspaces" to="/keyspaces" count={keyspaces.length} />
</li>
<li>
<NavRailLink icon={Icons.keyS} text="Schemas" to="/schemas" count={0} />
<NavRailLink icon={Icons.keyS} text="Schemas" to="/schemas" count={schemas.length} />
</li>
<li>
<NavRailLink icon={Icons.keyT} text="Tablets" to="/tablets" count={(tabletData || []).length} />
<NavRailLink icon={Icons.keyT} text="Tablets" to="/tablets" count={tablets.length} />
</li>
</ul>

Expand Down
50 changes: 0 additions & 50 deletions web/vtadmin/src/components/TabletList.tsx

This file was deleted.

Loading