frontend: ui for schema registry v1

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9189
Co-authored-by: nevermore <31686586+OjasWadhwani@users.noreply.github.com>
GitOrigin-RevId: 8a9091442967cb1603c04e5951e2dc2adf7d918b
This commit is contained in:
Rishichandra Wawhal 2023-06-01 18:49:37 +05:30 committed by hasura-bot
parent 34e172d576
commit 44aa0f00e8
26 changed files with 40683 additions and 307 deletions

View File

@ -3,6 +3,9 @@ schema:
- 'http://data.lux-dev.hasura.me/v1/graphql':
headers:
'x-hasura-admin-secret': 'randomsecret'
- 'http://schema-registry.lux-dev.hasura.me/v1/graphql':
headers:
'x-hasura-admin-secret': 'randomsecret'
documents: 'libs/console/legacy-ce/src/lib/features/ControlPlane/queries.ts'
generates:
libs/console/legacy-ce/src/lib/features/ControlPlane/generatedGraphQLTypes.ts:

View File

@ -98,6 +98,10 @@ export {
export { default as Spinner } from './lib/components/Common/Spinner/Spinner';
export { CommonScss };
export * from './lib/components/Services/Settings';
export {
SchemaRegistryContainer,
SchemaDetailsView,
} from './lib/features/SchemaRegistry';
export {
loadInconsistentObjects,
exportMetadata,

View File

@ -33,6 +33,7 @@ export const getEndpoints = (globals: typeof consoleGlobals) => {
}/v1/graphql`,
prometheusUrl: `${baseUrl}/v1/metrics`,
registerEETrial: `https://licensing.pro.hasura.io/v1/graphql`,
schemaRegistry: 'http://schema-registry.lux-dev.hasura.me/v1/graphql',
// registerEETrial: `http://licensing.lux-dev.hasura.me/v1/graphql`,
};

View File

@ -161,6 +161,7 @@ export const eventsPrefix = 'events';
export const scheduledEventsPrefix = 'cron';
export const adhocEventsPrefix = 'one-off-scheduled-events';
export const dataEventsPrefix = 'data';
export const schemaRegsitryPrefix = 'sh';
export const getSTRoute = (type: string | undefined, relativeRoute: string) => {
if (type === 'relative') {

View File

@ -15,6 +15,7 @@ import {
import { useEELiteAccess } from '../../../features/EETrial';
import { getQueryResponseCachingRoute } from '../../../utils/routeUtils';
import { isCloudConsole } from '../../../utils/cloudConsole';
export interface Metadata {
inconsistentObjects: Record<string, unknown>[];
@ -31,7 +32,8 @@ type SectionDataKey =
| 'security'
| 'monitoring'
| 'performance'
| 'about';
| 'about'
| 'graphql';
const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
const eeLiteAccess = useEELiteAccess(globals);
@ -45,6 +47,21 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
items: [],
};
if (isCloudConsole(globals)) {
sectionsData.graphql = {
key: 'graphql',
label: 'GraphQL',
items: [
{
key: 'schema-registry',
label: 'Schema Registry',
route: '/settings/schema-registry',
dataTestVal: 'metadata-schema-registry-link',
},
],
};
}
sectionsData.metadata.items.push({
key: 'actions',
label: 'Metadata Actions',

View File

@ -248,3 +248,18 @@ mutation addSurveyAnswerV2 ($responses: [SurveyResponseV2]!, $surveyName: String
}
}
`);
/**
* GraphQL mutation to accept schema registry feature requests
* */
export const ADD_SCHEMA_REGISTRY_FEATURE_REQUEST = gql(`
mutation addSchemaRegistryFeatureRequest ($details:jsonb!) {
addFeatureRequest (payload: {
type: "schema-registry-feature-request"
details: $details
}) {
status
}
}
`);

View File

@ -0,0 +1,53 @@
import * as React from 'react';
import { RoleBasedSchema } from '../types';
export const ChangeSummary: React.VFC<{
changes: RoleBasedSchema['changes'];
}> = props => {
const { changes } = props;
if (!changes) {
return <span>Unknown</span>;
}
const numBreakingChanges = changes.filter(
c => c.criticality.level === 'BREAKING'
).length;
const numDangerousChanges = changes.filter(
c => c.criticality.level === 'DANGEROUS'
).length;
const numSafeChanges = changes.filter(
c => c.criticality.level === 'NON_BREAKING'
).length;
if (
numBreakingChanges === 0 &&
numDangerousChanges === 0 &&
numSafeChanges === 0
) {
return <span>No changes detected</span>;
}
return (
<div className="flex flex-row justify-between w-1/5">
<div className="flex-col">
<div className="flex text-red-600 text-2xl font-bold">
{numBreakingChanges}
</div>
<span>Breaking</span>
</div>
<div className="flex-col">
<div className="flex text-red-800 text-2xl font-bold">
{numDangerousChanges}
</div>
<span>Dangerous</span>
</div>
<div className="flex-col">
<div className="flex text-green-600 text-2xl font-bold">
{numSafeChanges}
</div>
<span>Safe</span>
</div>
</div>
);
};

View File

@ -0,0 +1,44 @@
import * as React from 'react';
import { SchemasList } from './SchemasList';
import { FeatureRequest } from './FeatureRequest';
import globals from '../../../Globals';
import { SCHEMA_REGISTRY_FEATURE_NAME } from '../constants';
const Header: React.VFC = () => {
return (
<div className="flex w-full">
<h1 className="text-xl font-semibold">GraphQL Schema Registry</h1>
</div>
);
};
const Body: React.VFC<{ hasFeatureAccess: boolean }> = props => {
const { hasFeatureAccess } = props;
if (!hasFeatureAccess) {
return <FeatureRequest />;
}
return (
<div className="flex w-full">
<SchemasList />
</div>
);
};
export const SchemaRegistryContainer: React.VFC = () => {
const hasFeatureAccess = globals.allowedLuxFeatures.includes(
SCHEMA_REGISTRY_FEATURE_NAME
);
return (
<div className="p-4 flex flex-col w-full">
<div className="flex w-full mb-md">
<Header />
</div>
<div className="flex w-full mb-md">
<Body hasFeatureAccess={hasFeatureAccess} />
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { ChangeLevel } from '../types';
export const CountLabel: React.VFC<{
count: number;
type: ChangeLevel;
}> = props => {
const { count, type } = props;
let textColor = 'text-green-500';
let backgroundColor = 'bg-green-100';
switch (type) {
case 'BREAKING': {
textColor = 'text-red-500';
backgroundColor = 'bg-red-100';
break;
}
case 'DANGEROUS': {
textColor = 'text-yellow-500';
backgroundColor = 'bg-yellow-100';
break;
}
case 'NON_BREAKING': {
textColor = 'text-green-500';
backgroundColor = 'bg-green-100';
break;
}
default:
break;
}
return (
<div className={`rounded ${backgroundColor}`}>
<span className={`font-bold ${textColor} mx-2`}>{count}</span>
</div>
);
};

View File

@ -0,0 +1,75 @@
import * as React from 'react';
import { Button } from '../../../new-components/Button';
import { GraphQLError } from 'graphql';
import { hasuraToast } from '../../../new-components/Toasts';
import { useSubmitSchemaRegistryFeatureRequest } from '../hooks/useSubmitSchemaRegistryFeatureRequest';
export const FeatureRequest = () => {
const onSuccess = () => {
hasuraToast({
title: 'We have received your feature request for Schema Registry.',
message: 'Our team will enable the feature for you soon.',
type: 'success',
});
};
const onError = (error?: GraphQLError) => {
const errorMessage = error?.message || '';
if (errorMessage.includes('request already submitted')) {
hasuraToast({
title: 'We have received your feature request for Schema Registry.',
message: 'Our team will enable the feature for you soon.',
type: 'success',
});
} else {
hasuraToast({
title: 'Error requesting feature access',
message: 'Please try again in some time.',
type: 'error',
});
}
};
const { onSubmit, loading } = useSubmitSchemaRegistryFeatureRequest(
onSuccess,
onError
);
const onRequest = () => {
onSubmit();
};
return (
<div className="bg-white w-[50%] p-4 border-neutral-200 semi-rounded">
<span className="mb-sm text-muted">
The Hasura Schema Registry work is aimed to make your Hasura GraphQL
schema changes more reliable, prevent breaking changes in your schema
and make collaboration across large teams, micro services and roles much
more manageable and predictable.{' '}
<a href="" target="_blank" rel="noreferrer noopener">
{' '}
Read more.
</a>
</span>
<div className="mb-sm">
<img
src="https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/schema-registry-preview-screenshot.png"
alt="Schema Registry Preview Image"
/>
</div>
<div className="flex justify-start w-full items-center">
<Button
mode="primary"
className={`mr-sm ${loading ? 'cursor-not-allowed' : ''}`}
disabled={loading}
onClick={onRequest}
>
{loading ? 'Requesting...' : 'Request access'}
</Button>
<h4 className="font-italics text-muted">
This feature is currently in closed alpha.
</h4>
</div>
</div>
);
};

View File

@ -0,0 +1,241 @@
import React, { useMemo } from 'react';
import LZString from 'lz-string';
import moment from 'moment';
import { useGetSchema } from '../hooks/useGetSchema';
import { Tabs } from '../../../new-components/Tabs';
import { SchemaRow } from './SchemaRow';
import { ChangeSummary } from './ChangeSummary';
import { FindIfSubStringExists, schemaTransformFn } from '../utils';
import { Link } from 'react-router';
import { RoleBasedSchema, Schema } from '../types';
import { FaHome, FaAngleRight, FaFileImport, FaSearch } from 'react-icons/fa';
import { Input } from '../../../new-components/Form';
import AceEditor from 'react-ace';
export const Breadcrumbs = () => (
<div className="flex items-center space-x-xs mb-4">
<Link
to="/settings/schema-registry"
className="cursor-pointer flex items-center text-muted hover:text-gray-900"
>
<FaHome className="mr-1.5" />
<span className="text-sm">Schema</span>
</Link>
<FaAngleRight className="text-muted" />
<div className="cursor-pointer flex items-center text-yellow-500">
<FaFileImport className="mr-1.5" />
<span className="text-sm">Roles</span>
</div>
</div>
);
type SchemaDetailsViewProps = {
params: {
id: string;
};
};
export const SchemaDetailsView = (props: SchemaDetailsViewProps) => {
const { id: schemaId } = props.params;
const fetchSchemaResponse = useGetSchema(schemaId);
const { kind } = fetchSchemaResponse;
switch (kind) {
case 'loading':
return <p>Loading...</p>;
case 'error':
return <p>Error: {fetchSchemaResponse.message}</p>;
case 'success': {
const transformedData = schemaTransformFn(fetchSchemaResponse.response);
return <SchemasDetails schema={transformedData} />;
}
}
};
const SchemasDetails: React.VFC<{
schema: Schema;
}> = props => {
const { schema } = props;
const [tabState, setTabState] = React.useState('graphql');
// We know for sure only one roleBasedSchema exists
const roleBasedSchema = schema.roleBasedSchemas[0];
const published = moment(schema.created_at);
return (
<div className="mx-4 mt-4">
<Breadcrumbs />
<div className="flex mb-sm">
<span className="font-bold text-xl text-black mr-4">
{roleBasedSchema.role}:{schema.entry_hash.substr(0, 16)}
</span>
</div>
<div className="border-neutral-200 bg-white border w-3/5">
<div className="w-full flex bg-gray-100 px-4 py-2">
<div className="flex text-base w-[69%] justify-start">
<span className="text-sm font-bold">SCHEMA</span>
</div>
<div className="flex text-base w-[28%] justify-between">
<span className="text-sm font-bold">BREAKING</span>
<span className="text-sm font-bold">DANGEROUS</span>
<span className="text-sm font-bold">SAFE</span>
</div>
</div>
<div className="ml-4 mb-2 ">
<SchemaRow
role={roleBasedSchema.role || ''}
changes={roleBasedSchema.changes || []}
/>
<div className="flex mt-4">
<div className="flex-col w-1/4">
<div className="font-bold text-gray-500">Published</div>
<span>{published.fromNow()}</span>
</div>
<div className="flex-col w-3/4">
<div className="font-bold text-gray-500">Hash</div>
<span className="font-bold bg-gray-100 px-1 rounded text-sm">
{roleBasedSchema.hash}
</span>
</div>
</div>
</div>
<div className="w-full h-full">
<Tabs
value={tabState}
onValueChange={state => setTabState(state)}
headerTabBackgroundColor="bg-white"
items={[
{
value: 'graphql',
label: 'GraphQL',
content: <SchemaView schema={roleBasedSchema.raw} />,
},
{
value: 'changes',
label: 'Changes',
content: <ChangesView changes={roleBasedSchema.changes} />,
},
]}
/>
</div>
</div>
</div>
);
};
export const SchemaView: React.VFC<{ schema: string }> = props => {
const { schema } = props;
const decompressedSchema = LZString.decompressFromBase64(schema);
return (
<div className="w-full p-sm">
<AceEditor
mode="graphqlschema"
fontSize={14}
width="100%"
theme="chrome"
name={`schema-registry-schema-modal-view-schema`}
value={decompressedSchema}
editorProps={{ $blockScrolling: true }}
setOptions={{ useWorker: false }}
/>
</div>
);
};
export const ChangesView: React.VFC<{
changes: RoleBasedSchema['changes'];
}> = props => {
const { changes } = props;
const [searchText, setSearchText] = React.useState('');
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) =>
setSearchText(e.target.value);
const changesList = useMemo(() => {
if (!searchText) return changes;
return changes?.filter(change =>
FindIfSubStringExists(change.message, searchText)
);
}, [searchText, changes]);
if (!changes) {
return <span>Could not compute what changed in this GraphQL Schema</span>;
}
if (!changes.length) {
return <span className="ml-4">No changes!</span>;
}
const breakingChanges =
changesList && changesList.filter(c => c.criticality.level === 'BREAKING');
const dangerousChanges =
changesList && changesList.filter(c => c.criticality.level === 'DANGEROUS');
const safeChanges =
changesList &&
changesList.filter(c => c.criticality.level === 'NON_BREAKING');
return (
<div className="flex-col m-8">
<div className="flex-col">
<div className="font-bold text-md mb-8 text-gray-500">
Change Summary
</div>
<ChangeSummary changes={changes} />
</div>
<div className="flex w-full border-b border-gray-300 my-8" />
<div className="flex w-full mb-4 justify-between">
<div className="flex font-bold text-gray-600">Changes</div>
<label className="block">
<Input
type="text"
placeholder="Search"
name="search"
icon={<FaSearch />}
iconPosition="start"
onChange={handleSearch}
/>
</label>
</div>
{breakingChanges && breakingChanges.length > 0 && (
<div className="flex flex-col">
{breakingChanges.map(c => {
return (
<div className="text-red-600 border border-gray-400 p-2">
{c.message}
</div>
);
})}
</div>
)}
{dangerousChanges && dangerousChanges.length > 0 && (
<div className="flex flex-col">
{dangerousChanges.map(c => {
return (
<div className="text-red-800 border border-gray-400 p-2">
{c.message}
</div>
);
})}
</div>
)}
{safeChanges && safeChanges.length > 0 && (
<div className="flex flex-col">
{safeChanges.map(c => {
return (
<div className="text-green-600 border border-gray-400 p-2">
{c.message}
</div>
);
})}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,128 @@
import * as React from 'react';
import { Dialog } from '../../../new-components/Dialog';
import { Tabs } from '../../../new-components/Tabs';
import { Schema } from '../types';
import AceEditor from 'react-ace';
import 'brace/mode/html';
import 'brace/mode/markdown';
import 'brace/theme/github';
import 'brace/theme/chrome';
type Props = {
onClose: VoidFunction;
schema: Schema | null;
};
export const SchemaModal: React.VFC<Props> = props => {
const [tabState, setTabState] = React.useState('schema');
const { schema, onClose } = props;
if (!schema) {
return null;
}
return (
<Dialog
hasBackdrop
size="lg"
onClose={onClose}
title="GraphQL Schema Details"
description=""
>
<div className="w-full h-full p-md">
<Tabs
value={tabState}
onValueChange={state => setTabState(state)}
items={[
{
value: 'schema',
label: 'GraphQL Schema',
content: <SchemaView schema={schema.raw} />,
},
{
value: 'diff',
label: 'Changes',
content: <DiffView changes={schema.changes} />,
},
]}
/>
</div>
</Dialog>
);
};
export const SchemaView: React.VFC<{ schema: string }> = props => {
const { schema } = props;
return (
<div className="w-full p-sm">
<AceEditor
mode="graphql"
fontSize={14}
width="100%"
theme="github"
name={`schema-registry-schema-modal-view-schema`}
value={schema}
editorProps={{ $blockScrolling: true }}
setOptions={{ useWorker: false }}
/>
</div>
);
};
export const DiffView: React.VFC<{ changes: Schema['changes'] }> = props => {
const { changes } = props;
if (!changes) {
return <span>Could not compute what changed in this GraphQL Schema</span>;
}
if (!changes.length) {
return <span>No changes!</span>;
}
const breakingChanges = changes.filter(
c => c.criticality.level === 'BREAKING'
);
const dangerousChanges = changes.filter(
c => c.criticality.level === 'DANGEROUS'
);
const safeChanges = changes.filter(
c => c.criticality.level === 'NON_BREAKING'
);
return (
<div className="w-full p-sm">
{breakingChanges.length && (
<div className="flex flex-col w-full mb-sm">
<b className="mb-xs">Breaking Changes</b>
<ul className="marker:text-red-600 list-outside list-disc ml-6">
{breakingChanges.map(c => {
return <li>{c.message}</li>;
})}
</ul>
</div>
)}
{dangerousChanges.length && (
<div className="flex flex-col w-full mb-sm">
<b className="mb-xs">Dangerous Changes</b>
<ul className="marker:text-yellow-500 list-outside list-disc ml-6">
{dangerousChanges.map(c => {
return <li>{c.message}</li>;
})}
</ul>
</div>
)}
{safeChanges.length && (
<div className="flex flex-col w-full mb-sm">
<b className="mb-xs">Safe Changes</b>
<ul className="marker:text-lime-500 list-outside list-disc ml-6">
{safeChanges.map(c => {
return <li>{c.message}</li>;
})}
</ul>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import { CountLabel } from './CountLabel';
import { CapitalizeFirstLetter } from '../utils';
import { SchemaChange } from '../types';
import { FaChevronRight } from 'react-icons/fa';
export const SchemaRow: React.VFC<{
role: string;
changes: SchemaChange[];
onClick?: VoidFunction;
}> = props => {
const { role, changes, onClick } = props;
const countBreakingChanges = changes?.filter(
c => c.criticality.level === 'BREAKING'
).length;
const countDangerousChanges = changes?.filter(
c => c.criticality.level === 'DANGEROUS'
).length;
const countSafeChanges = changes?.filter(
c => c.criticality.level === 'NON_BREAKING'
).length;
return (
<div className="w-full flex my-2">
<div className="flex text-base w-[72%] justify-start">
<span className="text-sm font-bold bg-gray-100 p-1 rounded">
{CapitalizeFirstLetter(role)}
</span>
</div>
<div className="flex text-base w-[28%] justify-between">
<CountLabel count={countBreakingChanges} type="BREAKING" />
<CountLabel count={countDangerousChanges} type="DANGEROUS" />
<CountLabel count={countSafeChanges} type="NON_BREAKING" />
</div>
{onClick && (
<div
className="flex text-base w-[2%] justify-end mt-[6px]"
role="button"
onClick={onClick}
>
<FaChevronRight />
</div>
)}
{!onClick && <div className="flex w-[4%] justify-end"></div>}
</div>
);
};

View File

@ -0,0 +1,125 @@
import React from 'react';
import { SchemaRow } from './SchemaRow';
import { useGetSchemaList } from '../hooks/useGetSchemaList';
import { Schema, RoleBasedSchema } from '../types';
import moment from 'moment';
import { browserHistory } from 'react-router';
import globals from '../../../Globals';
import { schemaListTransformFn } from '../utils';
export const SchemasList = () => {
const projectID = globals.hasuraCloudProjectId || '';
const fetchSchemaResponse = useGetSchemaList(projectID);
const { kind } = fetchSchemaResponse;
switch (kind) {
case 'loading':
return <p>Loading...</p>;
case 'error':
return <p>Error: {fetchSchemaResponse.message}</p>;
case 'success': {
const schemaList = schemaListTransformFn(fetchSchemaResponse.response);
return <Tabularised schemas={schemaList} />;
}
}
};
export const Tabularised: React.VFC<{ schemas: Schema[] }> = props => {
const { schemas } = props;
return (
<div className="overflow-x-auto rounded-sm border-neutral-200 bg-gray-100 border w-3/5">
<div className="w-full flex bg-gray-100 px-4 py-2">
<div className="flex text-base w-[69%] justify-start">
<span className="text-sm font-bold">SCHEMA</span>
</div>
<div className="flex text-base w-[28%] justify-between">
<span className="text-sm font-bold">BREAKING</span>
<span className="text-sm font-bold">DANGEROUS</span>
<span className="text-sm font-bold">SAFE</span>
</div>
{/* <div className="flex text-base w-[2%] justify-end">he</div> */}
</div>
<div className="flex flex-col w-full">
{schemas.length ? (
schemas.map(schema => {
return (
<SchemaCard
createdAt={schema.created_at}
schemaId={schema.id}
hash={schema.hash}
roleBasedSchemas={schema.roleBasedSchemas}
/>
);
})
) : (
<div className="white border-t border-neutral-200">
<div className="p-xs" data-test="label-no-domain-found">
No schemas published to the schema registry yet
</div>
</div>
)}
</div>
</div>
);
};
const SchemaCard: React.VFC<{
createdAt: string;
hash: string;
schemaId: string;
roleBasedSchemas: RoleBasedSchema[];
}> = props => {
const { createdAt, hash, roleBasedSchemas } = props;
const published = moment(createdAt);
return (
<div className="w-full flex-col px-4 py-2 mb-2 bg-white">
<div className="flex flex-col w-1/2">
<div className="flex mt-4">
<div className="flex-col w-1/4">
<div className="font-bold text-gray-500">Published</div>
<span>{published.fromNow()}</span>
</div>
<div className="flex-col w-3/4">
<div className="font-bold text-gray-500">Hash</div>
<span className="font-bold bg-gray-100 px-1 rounded text-sm">
{hash}
</span>
</div>
</div>
</div>
<div className="flex-col w-full mt-8">
{roleBasedSchemas.length ? (
roleBasedSchemas.map((roleBasedSchema, index) => {
return (
<div className="flex-col w-full">
<SchemaRow
role={roleBasedSchema.role || ''}
changes={roleBasedSchema.changes || []}
onClick={() => {
browserHistory.push(
`${globals.urlPrefix}/settings/schema-registry/${roleBasedSchema.id}`
);
}}
/>
{!(index + 1 === roleBasedSchemas.length) ? (
<div className="flex w-full border-b border-gray-300" />
) : null}
</div>
);
})
) : (
<div className="white border-t border-neutral-200">
<div className="p-xs" data-test="label-no-domain-found">
No schemas published to the schema registry yet
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,9 @@
export const FETCH_REGISTRY_SCHEMAS_QUERY_NAME =
'FETCH_REGISTRY_SCHEMAS_QUERY_NAME';
export const FETCH_REGISTRY_SCHEMA_QUERY_NAME =
'FETCH_REGISTRY_SCHEMA_QUERY_NAME';
// 5 minutes as default stale time
export const SCHEMA_REGISTRY_REFRESH_TIME = 5 * 60 * 1000;
export const SCHEMA_REGISTRY_FEATURE_NAME = 'GraphQLSchemaRegistry';

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { useQuery } from 'react-query';
import { schemaRegsitryControlPlaneClient } from '../utils';
import { FETCH_REGSITRY_SCHEMA_QUERY } from '../queries';
import { GetRegistrySchemaResponseWithError } from '../types';
import {
FETCH_REGISTRY_SCHEMA_QUERY_NAME,
SCHEMA_REGISTRY_REFRESH_TIME,
} from '../constants';
type FetchSchemaResponse =
| {
kind: 'loading';
}
| {
kind: 'error';
message: string;
}
| {
kind: 'success';
response: NonNullable<GetRegistrySchemaResponseWithError['data']>;
};
export const useGetSchema = (schemaId: string): FetchSchemaResponse => {
const fetchRegistrySchemaQueryFn = (schemaId: string) => {
return schemaRegsitryControlPlaneClient.query<
GetRegistrySchemaResponseWithError,
{ schemaId: string }
>(FETCH_REGSITRY_SCHEMA_QUERY, {
schemaId: schemaId,
});
};
const { data, error, isLoading } = useQuery({
queryKey: FETCH_REGISTRY_SCHEMA_QUERY_NAME,
queryFn: () => fetchRegistrySchemaQueryFn(schemaId),
refetchOnMount: 'always',
refetchOnWindowFocus: true,
staleTime: SCHEMA_REGISTRY_REFRESH_TIME,
});
if (isLoading) {
return {
kind: 'loading',
};
}
if (error || !data || !!data?.errors || !data?.data) {
return {
kind: 'error',
message: 'error',
};
}
return {
kind: 'success',
response: data.data,
};
};

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { useQuery } from 'react-query';
import { FETCH_REGSITRY_SCHEMAS_QUERY } from '../queries';
import { schemaRegsitryControlPlaneClient } from '../utils';
import { GetSchemaListResponseWithError } from '../types';
import {
FETCH_REGISTRY_SCHEMAS_QUERY_NAME,
SCHEMA_REGISTRY_REFRESH_TIME,
} from '../constants';
type FetchSchemaResponse =
| {
kind: 'loading';
}
| {
kind: 'error';
message: string;
}
| {
kind: 'success';
response: NonNullable<GetSchemaListResponseWithError['data']>;
};
export const useGetSchemaList = (projectId: string): FetchSchemaResponse => {
const fetchRegistrySchemasQueryFn = (projectId: string) => {
return schemaRegsitryControlPlaneClient.query<
GetSchemaListResponseWithError,
{ projectId: string }
>(FETCH_REGSITRY_SCHEMAS_QUERY, {
projectId: projectId,
});
};
const { data, error, isLoading } = useQuery({
queryKey: FETCH_REGISTRY_SCHEMAS_QUERY_NAME,
queryFn: () => fetchRegistrySchemasQueryFn(projectId),
refetchOnMount: 'always',
refetchOnWindowFocus: true,
staleTime: SCHEMA_REGISTRY_REFRESH_TIME,
});
if (isLoading) {
return {
kind: 'loading',
};
}
if (error || !data || !!data.errors || !data.data) {
return {
kind: 'error',
message: 'error',
};
}
return {
kind: 'success',
response: data.data,
};
};

View File

@ -0,0 +1,47 @@
import { useMutation } from 'react-query';
import { GraphQLError } from 'graphql';
import globals from '../../../Globals';
import {
controlPlaneClient,
ADD_SCHEMA_REGISTRY_FEATURE_REQUEST,
AddSchemaRegistryFeatureRequestMutation,
} from '../../ControlPlane';
export const useSubmitSchemaRegistryFeatureRequest = (
successCb: () => void,
errorCb: (error?: GraphQLError) => void
) => {
const addSchemaRegistryFtRequestMutation = () => {
return controlPlaneClient.query<{
data: AddSchemaRegistryFeatureRequestMutation;
errors: [GraphQLError];
}>(ADD_SCHEMA_REGISTRY_FEATURE_REQUEST, {
details: {
source: 'cloud-console',
project_id: globals.hasuraCloudProjectId,
},
});
};
const mutation = useMutation(addSchemaRegistryFtRequestMutation, {
onSuccess: data => {
if (data.errors && data.errors.length > 0) {
errorCb(data.errors[0]);
} else {
successCb();
}
},
onError: () => {
errorCb();
},
});
const onSubmit = () => {
mutation.mutate();
};
return {
onSubmit,
loading: mutation.isLoading,
};
};

View File

@ -0,0 +1,2 @@
export { SchemaRegistryContainer } from './components/Container';
export { SchemaDetailsView } from './components/SchemaDetails';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
import { parse as gql } from 'graphql';
export const FETCH_REGSITRY_SCHEMAS_QUERY = gql(`
query fetchRegistrySchemas($projectId: uuid!) {
schema_registry_dumps(
where: {_and: [{project_id: {_eq: $projectId}, hasura_schema_role: {_eq: "admin"}}]},
order_by: {change_recorded_at: desc}
) {
change_recorded_at
schema_hash
entry_hash
id
sibling_schemas {
id
entry_hash
change_recorded_at
created_at
hasura_schema_role
schema_sdl
diff_with_previous_schema {
current_schema_hash
former_schema_hash
former_schema_id
current_schema_id
schema_diff_data
}
}
}
}
`);
export const FETCH_REGSITRY_SCHEMA_QUERY = gql(`
query fetchRegistrySchema ($schemaId: uuid!) {
schema_registry_dumps (
where: {
id: {
_eq: $schemaId
}
}
) {
id
entry_hash
schema_hash
change_recorded_at
created_at
hasura_schema_role
schema_sdl
diff_with_previous_schema {
current_schema_hash
former_schema_hash
former_schema_id
current_schema_id
schema_diff_data
}
}
}
`);

View File

@ -0,0 +1,88 @@
import { GraphQLError } from 'graphql';
export type SafeSchemaChange = 'NON_BREAKING';
export type DangerousSchemaChange = 'DANGEROUS';
export type BreakingSchemaChange = 'BREAKING';
export type ChangeLevel =
| DangerousSchemaChange
| BreakingSchemaChange
| SafeSchemaChange;
export type SchemaChange = {
criticality: {
level: ChangeLevel;
reason?: string;
};
type: string;
meta: any;
message: string;
path: string;
};
export type RoleBasedSchema = {
raw: string;
role: string;
entry_hash: string;
hash: string;
id: string;
changes?: SchemaChange[];
};
export type Schema = {
id: string;
hash: string;
entry_hash: string;
created_at: string;
roleBasedSchemas: RoleBasedSchema[];
};
/*
Schema Registry Query types
*/
export type GetSchemaListResponseWithError = {
data?: GetSchemaListQueryResponse;
errors?: GraphQLError[];
};
export type GetSchemaListQueryResponse = {
schema_registry_dumps: SchemaRegistryDumpWithSiblingSchema[];
};
export type SchemaRegistryDump = {
id: string;
entry_hash: string;
schema_hash: string;
change_recorded_at: string;
created_at: string;
hasura_schema_role: string;
schema_sdl: string;
};
export type SchemaRegistryDumpWithSiblingSchema = SchemaRegistryDump & {
sibling_schemas: SiblingSchema[];
};
export type SiblingSchema = SchemaRegistryDump & {
diff_with_previous_schema: SchemaDiffData[];
};
export type SchemaDiffData = {
current_schema_hash: string;
former_schema_hash: string;
former_schema_id: string;
current_schema_id: string;
// For the following, using the same type (SchemaChange) used for UI, if ever the query response type changes
// a refactor in UI will also be required thus changing the inherent type
schema_diff_data: SchemaChange[];
};
export type GetRegistrySchemaResponseWithError = {
data?: GetRegistrySchemaQueryResponse;
errors?: GraphQLError[];
};
export type GetRegistrySchemaQueryResponse = {
schema_registry_dumps: SiblingSchema[];
};

View File

@ -0,0 +1,102 @@
import { createControlPlaneClient } from '../ControlPlane';
import endpoints from '../../Endpoints';
import {
Schema,
RoleBasedSchema,
SchemaChange,
GetSchemaListResponseWithError,
SchemaRegistryDumpWithSiblingSchema,
SiblingSchema,
GetRegistrySchemaResponseWithError,
} from './types';
export const CapitalizeFirstLetter = (str: string) => {
return str[0].toUpperCase() + str.slice(1);
};
export const FindIfSubStringExists = (
originalString: string,
subString: string
) => {
return originalString.toLowerCase().includes(subString.toLocaleLowerCase());
};
export const schemaRegsitryLuxDataEndpoint = endpoints.schemaRegistry;
export const schemaRegsitryControlPlaneClient = createControlPlaneClient(
schemaRegsitryLuxDataEndpoint,
{}
);
export const schemaListTransformFn = (
fetchedData: NonNullable<GetSchemaListResponseWithError['data']>
) => {
const dumps = fetchedData.schema_registry_dumps || [];
const schemaList: Schema[] = [];
dumps.forEach((dump: SchemaRegistryDumpWithSiblingSchema) => {
const roleBasedSchemas: RoleBasedSchema[] = [];
dump.sibling_schemas.forEach((childSchema: SiblingSchema) => {
const changes: SchemaChange[] = [
...(childSchema.diff_with_previous_schema?.[0]?.schema_diff_data || []),
];
const roleBasedSchema: RoleBasedSchema = {
raw: childSchema.schema_sdl,
role: childSchema.hasura_schema_role,
hash: childSchema.schema_hash,
entry_hash: dump.entry_hash,
id: childSchema.id,
changes: changes,
};
roleBasedSchemas.push(roleBasedSchema);
});
const schema: Schema = {
hash: dump.schema_hash,
created_at: dump.change_recorded_at,
id: dump.id,
entry_hash: dump.entry_hash,
roleBasedSchemas: roleBasedSchemas,
};
schemaList.push(schema);
});
return schemaList;
};
export const schemaTransformFn = (
fetchedData: NonNullable<GetRegistrySchemaResponseWithError['data']>
) => {
const data = fetchedData.schema_registry_dumps[0] || [];
const roleBasedSchemas: RoleBasedSchema[] = [];
const changes = [
...(data.diff_with_previous_schema?.[0]?.schema_diff_data || []),
];
const roleBasedSchema: RoleBasedSchema = {
id: data.id,
hash: data.schema_hash,
raw: data.schema_sdl,
role: data.hasura_schema_role,
entry_hash: data.entry_hash,
changes: changes,
};
roleBasedSchemas.push(roleBasedSchema);
const schema: Schema = {
hash: data.schema_hash,
entry_hash: data.entry_hash,
created_at: data.change_recorded_at,
id: data.id,
roleBasedSchemas: roleBasedSchemas,
};
return schema;
};

View File

@ -9,19 +9,26 @@ interface TabsItem {
content: React.ReactNode;
}
interface TabsProps extends React.ComponentProps<typeof RadixTabs.Root> {
interface TabsCustomProps extends React.ComponentProps<typeof RadixTabs.Root> {
headerTabBackgroundColor?: string;
}
interface TabsProps extends TabsCustomProps {
items: TabsItem[];
}
export const Tabs: React.FC<TabsProps> = props => {
const { items, ...rest } = props;
const { headerTabBackgroundColor, items, ...rest } = props;
const backgroundColor = headerTabBackgroundColor ?? 'bg-legacybg';
return (
<RadixTabs.Root
defaultValue={rest?.defaultValue ?? items[0]?.value}
{...rest}
>
<RadixTabs.List aria-label="Tabs">
<div className="border-b border-gray-200 bg-legacybg flex space-x-4">
<div
className={`border-b border-gray-200 ${backgroundColor} flex space-x-4`}
>
{items.map(({ value: itemValue, label, icon }) => (
<RadixTabs.Trigger key={itemValue} value={itemValue} asChild>
<button

View File

@ -51,6 +51,8 @@ import {
MultipleAdminSecretsPage,
MultipleJWTSecretsPage,
SingleSignOnPage,
SchemaRegistryContainer,
SchemaDetailsView,
} from '@hasura/console-legacy-ce';
import AccessDeniedComponent from './components/AccessDenied/AccessDenied';
@ -369,6 +371,8 @@ const routes = store => {
>
<Route path="settings" component={metadataContainer(connect)}>
<IndexRedirect to="metadata-actions" />
<Route path="schema-registry" component={SchemaRegistryContainer} />
<Route path="schema-registry/:id" component={SchemaDetailsView} />
<Route
path="metadata-actions"
component={metadataOptionsContainer(connect)}