mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
console: schema change alerts UI
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9443 Co-authored-by: Rishichandra Wawhal <27274869+wawhal@users.noreply.github.com> GitOrigin-RevId: a4eb6bd7922792743fe21bb6a806a5a3e3379ede
This commit is contained in:
parent
44dd00fc37
commit
9e5795ec0f
@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Dialog } from '../../../new-components/Dialog';
|
||||
import { Checkbox } from '../../../new-components/Form';
|
||||
import { FaClock, FaBan, FaBell } from 'react-icons/fa';
|
||||
import { useGetAlertConfig } from '../hooks/useGetAlertConfig';
|
||||
import { CapitalizeFirstLetter } from '../utils';
|
||||
import globals from '../../../Globals';
|
||||
import { AlertHeader } from './AlertsHeader';
|
||||
import { CustomDialogFooter } from './CustomDialogFooter';
|
||||
import { ConfigKey } from '../types';
|
||||
|
||||
type DialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const configKeys: ConfigKey[] = ['breaking', 'dangerous', 'safe'];
|
||||
|
||||
const defaultAlertConfig: Record<string, boolean> = {
|
||||
safe: true,
|
||||
breaking: true,
|
||||
dangerous: true,
|
||||
};
|
||||
|
||||
export const AlertsDialog: React.FC<DialogProps> = ({ onClose }) => {
|
||||
const projectID = globals.hasuraCloudProjectId || '';
|
||||
const fetchAlertConfigResponse = useGetAlertConfig(projectID);
|
||||
|
||||
const [config, setConfig] =
|
||||
React.useState<Record<ConfigKey, boolean>>(defaultAlertConfig);
|
||||
|
||||
const { kind } = fetchAlertConfigResponse;
|
||||
const alertConfig =
|
||||
kind === 'success' &&
|
||||
fetchAlertConfigResponse.response.schema_registry_alerts.length
|
||||
? fetchAlertConfigResponse.response.schema_registry_alerts[0].config
|
||||
: defaultAlertConfig;
|
||||
|
||||
// initialise checkboxes
|
||||
React.useEffect(() => {
|
||||
if (kind === 'success') {
|
||||
setConfig(alertConfig);
|
||||
}
|
||||
}, [alertConfig]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
hasBackdrop
|
||||
onClose={kind === 'error' ? onClose : undefined}
|
||||
footer={
|
||||
kind === 'success' ? (
|
||||
<CustomDialogFooter onClose={onClose} alertConfig={config} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{kind === 'loading' ? (
|
||||
<div className="flex items-top p-md">
|
||||
<AlertHeader
|
||||
icon={<FaClock className="w-9 h-9 mt-sm mr-md fill-current" />}
|
||||
title="Loading..."
|
||||
/>
|
||||
</div>
|
||||
) : kind === 'error' ? (
|
||||
<div className="flex items-top p-md">
|
||||
<AlertHeader
|
||||
icon={<FaBan className="w-9 h-9 mt-sm mr-md fill-red-500" />}
|
||||
title="Error"
|
||||
description={fetchAlertConfigResponse.message}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-top p-md">
|
||||
<div className="text-yellow-500">
|
||||
<FaBell className="w-9 h-9 mt-sm mr-md fill-current" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Email Alerts</p>
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-14rem)]">
|
||||
<p className="m-0">
|
||||
Select the change categories for which an email should be
|
||||
sent!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col ml-8">
|
||||
{configKeys.map(c => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center mb-xs cursor-pointer w-auto"
|
||||
role="checkbox"
|
||||
onClick={() => {
|
||||
setConfig(prevConfig => ({
|
||||
...prevConfig,
|
||||
[c]: !prevConfig[c],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={config[c]} />
|
||||
<p>{CapitalizeFirstLetter(c)} changes</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
type AlertHeaderProps = {
|
||||
icon: React.ReactNode | React.ReactElement;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const AlertHeader: React.FC<AlertHeaderProps> = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="text-yellow-500">{icon}</div>
|
||||
<div>
|
||||
<p className="font-semibold">{title}</p>
|
||||
{description && (
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-14rem)]">
|
||||
<p className="m-0">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,19 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { SchemasList } from './SchemasList';
|
||||
import { FeatureRequest } from './FeatureRequest';
|
||||
import globals from '../../../Globals';
|
||||
import { SCHEMA_REGISTRY_FEATURE_NAME } from '../constants';
|
||||
import { FaBell } from 'react-icons/fa';
|
||||
import { IconTooltip } from '../../../new-components/Tooltip';
|
||||
import { AlertsDialog } from './AlertsDialog';
|
||||
import { Badge } from '../../../new-components/Badge';
|
||||
import { SCHEMA_REGISTRY_REF_URL } from '../constants';
|
||||
|
||||
const Header: React.VFC = () => {
|
||||
const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex w-full mb-sm">
|
||||
<h1 className="text-xl font-semibold">GraphQL Schema Registry</h1>
|
||||
<Badge className="mx-2" color="blue">
|
||||
BETA
|
||||
</Badge>
|
||||
<div className="flex w-3/5 mb-sm justify-between">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold">GraphQL Schema Registry</h1>
|
||||
<Badge className="mx-2" color="blue">
|
||||
BETA
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="flex text-lg mt-2 mr-2 cursor-pointer"
|
||||
role="button"
|
||||
onClick={() => setIsAlertModalOpen(true)}
|
||||
>
|
||||
<IconTooltip
|
||||
message="Alerts on GraphQL schema changes"
|
||||
icon={<FaBell />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className="text-muted w-auto"
|
||||
@ -23,6 +40,9 @@ const Header: React.VFC = () => {
|
||||
>
|
||||
What is Schema Registry?
|
||||
</a>
|
||||
{isAlertModalOpen && (
|
||||
<AlertsDialog onClose={() => setIsAlertModalOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import globals from '../../../Globals';
|
||||
import { Button } from '../../../new-components/Button';
|
||||
import { useSetEmailAlertConfig } from '../hooks/useSetAlertConfig';
|
||||
import { ConfigKey } from '../types';
|
||||
|
||||
type CustomDialogFooterProps = {
|
||||
onClose: () => void;
|
||||
alertConfig: Record<ConfigKey, boolean>;
|
||||
};
|
||||
|
||||
export const CustomDialogFooter: React.FC<CustomDialogFooterProps> = ({
|
||||
onClose,
|
||||
alertConfig,
|
||||
}) => {
|
||||
const projectID = globals.hasuraCloudProjectId || '';
|
||||
const { setEmailAlertMutation } = useSetEmailAlertConfig(onClose);
|
||||
|
||||
const onSet = React.useCallback(() => {
|
||||
setEmailAlertMutation.mutate({ projectId: projectID, config: alertConfig });
|
||||
}, [alertConfig]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between border-t border-gray-300 bg-white p-sm">
|
||||
<div>
|
||||
<p className="text-muted">
|
||||
Email alerts will be sent to the owner of this project.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<div className="ml-2">
|
||||
<Button
|
||||
mode="primary"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onSet();
|
||||
}}
|
||||
isLoading={setEmailAlertMutation.isLoading}
|
||||
disabled={setEmailAlertMutation.isLoading}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -2,6 +2,7 @@ export const FETCH_REGISTRY_SCHEMAS_QUERY_NAME =
|
||||
'FETCH_REGISTRY_SCHEMAS_QUERY_NAME';
|
||||
export const FETCH_REGISTRY_SCHEMA_QUERY_NAME =
|
||||
'FETCH_REGISTRY_SCHEMA_QUERY_NAME';
|
||||
export const FETCH_ALERT_CONFIG_QUERY_NAME = 'FETCH_ALERT_CONFIG_QUERY_NAME';
|
||||
|
||||
// 5 minutes as default stale time
|
||||
export const SCHEMA_REGISTRY_REFRESH_TIME = 5 * 60 * 1000;
|
||||
|
@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { FETCH_ALERT_CONFIG } from '../queries';
|
||||
import { schemaRegsitryControlPlaneClient } from '../utils';
|
||||
import { GetAlertConfigResponseWithError } from '../types';
|
||||
import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants';
|
||||
|
||||
type FetchAlertResponse =
|
||||
| {
|
||||
kind: 'loading';
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'success';
|
||||
response: NonNullable<GetAlertConfigResponseWithError['data']>;
|
||||
};
|
||||
|
||||
export const useGetAlertConfig = (projectId: string): FetchAlertResponse => {
|
||||
const fetchAlertConfigQueryFn = (projectId: string) => {
|
||||
return schemaRegsitryControlPlaneClient.query<
|
||||
GetAlertConfigResponseWithError,
|
||||
{ projectId: string }
|
||||
>(FETCH_ALERT_CONFIG, {
|
||||
projectId: projectId,
|
||||
});
|
||||
};
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: FETCH_ALERT_CONFIG_QUERY_NAME,
|
||||
queryFn: () => fetchAlertConfigQueryFn(projectId),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return {
|
||||
kind: 'loading',
|
||||
};
|
||||
}
|
||||
|
||||
if (error || !data || !!data.errors || !data.data) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'success',
|
||||
response: data.data,
|
||||
};
|
||||
};
|
@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { SET_ALERT_CONFIG } from '../queries';
|
||||
import { schemaRegsitryControlPlaneClient } from '../utils';
|
||||
import { SetAlertConfigResponseWithError } from '../types';
|
||||
import { FETCH_ALERT_CONFIG_QUERY_NAME } from '../constants';
|
||||
import { reactQueryClient } from '../../../lib/reactQuery';
|
||||
import { hasuraToast } from '../../../new-components/Toasts';
|
||||
|
||||
type setEmailAlertMutationFnArgs = {
|
||||
projectId: string;
|
||||
config: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export const useSetEmailAlertConfig = (onSuccess: VoidFunction) => {
|
||||
const setSchemaRegistryEmailAlertMutationFn = (variables: {
|
||||
projectId: string;
|
||||
config: Record<string, boolean>;
|
||||
}) => {
|
||||
return schemaRegsitryControlPlaneClient.query<
|
||||
SetAlertConfigResponseWithError,
|
||||
{ projectId: string; config: Record<string, boolean> }
|
||||
>(SET_ALERT_CONFIG, variables);
|
||||
};
|
||||
|
||||
const setEmailAlertMutation = useMutation(
|
||||
(args: setEmailAlertMutationFnArgs) =>
|
||||
setSchemaRegistryEmailAlertMutationFn(args),
|
||||
{
|
||||
onSuccess: () => {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'Email configuration set successfully!',
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
},
|
||||
onError: () => {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Error!',
|
||||
message:
|
||||
'Something went wrong while setting alerts for Schema Registry',
|
||||
});
|
||||
},
|
||||
onSettled: response => {
|
||||
if (response?.data) {
|
||||
reactQueryClient.refetchQueries(FETCH_ALERT_CONFIG_QUERY_NAME);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
setEmailAlertMutation,
|
||||
};
|
||||
};
|
@ -58,3 +58,27 @@ query fetchRegistrySchema ($schemaId: uuid!) {
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const FETCH_ALERT_CONFIG = gql(`
|
||||
query QueryAlerts($projectId: uuid!) {
|
||||
schema_registry_alerts(where: {project_id: {_eq: $projectId}}) {
|
||||
config
|
||||
alert_type
|
||||
id
|
||||
meta
|
||||
project_id
|
||||
slack_webhook
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const SET_ALERT_CONFIG = gql(`
|
||||
mutation UpsertEmailAlertConfig($projectId: uuid, $config: jsonb) {
|
||||
insert_schema_registry_alerts_one(object: {alert_type: email, config: $config, project_id: $projectId}, on_conflict: {constraint: schema_registry_alerts_project_id_alert_type_key, update_columns: config}) {
|
||||
id
|
||||
project_id
|
||||
alert_type
|
||||
config
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
@ -86,3 +86,39 @@ export type GetRegistrySchemaResponseWithError = {
|
||||
export type GetRegistrySchemaQueryResponse = {
|
||||
schema_registry_dumps: SiblingSchema[];
|
||||
};
|
||||
|
||||
export type GetAlertConfigResponseWithError = {
|
||||
data?: GetAlertConfigQueryResponse;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type GetAlertConfigQueryResponse = {
|
||||
schema_registry_alerts: SchemaRegistryAlert[];
|
||||
};
|
||||
|
||||
export type SchemaRegistryAlert = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
alert_type: string;
|
||||
config: Record<string, boolean>;
|
||||
slack_webhook: string;
|
||||
meta: Record<string, any>;
|
||||
};
|
||||
|
||||
export type SetAlertConfigResponseWithError = {
|
||||
data?: SetAlertConfigQueryResponse;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
|
||||
export type SetAlertConfigQueryResponse = {
|
||||
schema_registry_alerts: SchemaRegistryAlert;
|
||||
};
|
||||
|
||||
export type SetSchemaRegistryAlert = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
alert_type: string;
|
||||
config: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type ConfigKey = 'safe' | 'dangerous' | 'breaking';
|
||||
|
Loading…
Reference in New Issue
Block a user