feat(striker-ui): add mail recipient management

main
Tsu-ba-me 11 months ago
parent 1f5bf604e1
commit 3ea2fcc520
  1. 287
      striker-ui/components/ManageMailRecipient/AddMailRecipientForm.tsx
  2. 117
      striker-ui/components/ManageMailRecipient/AlertOverrideInputGroup.tsx
  3. 9
      striker-ui/components/ManageMailRecipient/EditMailRecipientForm.tsx
  4. 58
      striker-ui/components/ManageMailRecipient/ManageAlertOverride.tsx
  5. 160
      striker-ui/components/ManageMailRecipient/ManageMailRecipient.tsx
  6. 4
      striker-ui/components/ManageMailRecipient/index.ts
  7. 33
      striker-ui/components/ManageMailRecipient/schema.ts
  8. 11
      striker-ui/types/APIAlertOverride.d.ts
  9. 11
      striker-ui/types/APIMailRecipient.d.ts
  10. 61
      striker-ui/types/ManageMailRecipient.d.ts

@ -0,0 +1,287 @@
import { Grid, menuClasses as muiMenuClasses } from '@mui/material';
import { FC, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import ActionGroup from '../ActionGroup';
import api from '../../lib/api';
import FlexBox from '../FlexBox';
import FormSummary from '../FormSummary';
import handleAPIError from '../../lib/handleAPIError';
import mailRecipientListSchema from './schema';
import ManageAlertOverride from './ManageAlertOverride';
import MessageGroup from '../MessageGroup';
import OutlinedInputWithLabel from '../OutlinedInputWithLabel';
import SelectWithLabel from '../SelectWithLabel';
import { BodyText, SmallText } from '../Text';
import UncontrolledInput from '../UncontrolledInput';
import useFormikUtils from '../../hooks/useFormikUtils';
/**
* TODO: add descriptions to each item:
*
=head4 1 / critical
Alerts at this level will go to all recipients, except for those ignoring the source system entirely.
This is reserved for alerts that could lead to imminent service interruption or unexpected loss of redundancy.
Alerts at this level should trigger alarm systems for all administrators as well as management who may be impacted by service interruptions.
=head4 2 / warning
This is used for alerts that require attention from administrators. Examples include intentional loss of redundancy caused by load shedding, hardware in pre-failure, loss of input power, temperature anomalies, etc.
Alerts at this level should trigger alarm systems for administrative staff.
=head4 3 / notice
This is used for alerts that are generally safe to ignore, but might provide early warnings of developing issues or insight into system behaviour.
Alerts at this level should not trigger alarm systems. Periodic review is sufficient.
=head4 4 / info
This is used for alerts that are almost always safe to ignore, but may be useful in testing and debugging.
*
*/
const LEVEL_OPTIONS: SelectItem<number>[] = [
{
displayValue: (
<FlexBox spacing={0}>
<BodyText inheritColour fontWeight="inherit">
Critical
</BodyText>
<SmallText inheritColour whiteSpace="normal">
Alerts that could lead to imminent service interruption or unexpected
loss of redundancy.
</SmallText>
</FlexBox>
),
value: 1,
},
{
displayValue: (
<FlexBox spacing={0}>
<BodyText inheritColour fontWeight="inherit">
Warning
</BodyText>
<SmallText inheritColour whiteSpace="normal">
Alerts that require attention from administrators, such as redundancy
loss due to load shedding, hardware in pre-failure, input power loss,
temperature anomalies, etc.
</SmallText>
</FlexBox>
),
value: 2,
},
{
displayValue: (
<FlexBox spacing={0}>
<BodyText inheritColour fontWeight="inherit">
Notice
</BodyText>
<SmallText inheritColour whiteSpace="normal">
Alerts that are generally safe to ignore, but might provide early
warnings of developing issues or insight into system behaviour.
</SmallText>
</FlexBox>
),
value: 3,
},
{
displayValue: (
<FlexBox spacing={0}>
<BodyText inheritColour fontWeight="inherit">
Info
</BodyText>
<SmallText inheritColour whiteSpace="normal">
Alerts that are almost always safe to ignore, but may be useful in
testing and debugging.
</SmallText>
</FlexBox>
),
value: 4,
},
];
const MAP_TO_LEVEL_LABEL: Record<number, string> = {
1: 'Critical',
2: 'Warning',
3: 'Notice',
4: 'Info',
};
const AddMailRecipientForm: FC<AddMailRecipientFormProps> = (props) => {
const {
alertOverrideTargetOptions,
mailRecipientUuid,
previousFormikValues,
tools,
} = props;
const mrUuid = useMemo<string>(
() => mailRecipientUuid ?? uuidv4(),
[mailRecipientUuid],
);
const formikUtils = useFormikUtils<MailRecipientFormikValues>({
initialValues: previousFormikValues ?? {
[mrUuid]: {
alertOverrides: {},
email: '',
language: 'en_CA',
level: 2,
name: '',
uuid: mrUuid,
},
},
onSubmit: (values, { setSubmitting }) => {
const { [mrUuid]: mailRecipient } = values;
const { confirm } = tools;
let actionProceedText: string = 'Add';
let errorMessage: React.ReactNode = <>Failed to add mail recipient.</>;
let method: 'post' | 'put' = 'post';
let successMessage: React.ReactNode = <>Mail recipient added.</>;
let titleText: string = `Add mail recipient with the following?`;
let url: string = '/mail-recipient';
if (previousFormikValues) {
actionProceedText = 'Update';
errorMessage = <>Failed to update mail server.</>;
method = 'put';
successMessage = <>Mail recipient updated.</>;
titleText = `Update ${mailRecipient.name} with the following?`;
url += `/${mrUuid}`;
}
const { alertOverrides, uuid, ...mrBody } = mailRecipient;
confirm.prepare({
actionProceedText,
content: <FormSummary entries={mrBody} />,
onCancelAppend: () => setSubmitting(false),
onProceedAppend: () => {
confirm.loading(true);
api[method](url, mrBody)
.then(() => confirm.finish('Success', { children: successMessage }))
.catch((error) => {
const emsg = handleAPIError(error);
emsg.children = (
<>
{errorMessage} {emsg.children}
</>
);
confirm.finish('Error', emsg);
})
.finally(() => setSubmitting(false));
},
titleText,
});
confirm.open(true);
},
validationSchema: mailRecipientListSchema,
});
const { disabledSubmit, formik, formikErrors, handleChange } = formikUtils;
const emailChain = useMemo<string>(() => `${mrUuid}.email`, [mrUuid]);
const levelChain = useMemo<string>(() => `${mrUuid}.level`, [mrUuid]);
const nameChain = useMemo<string>(() => `${mrUuid}.name`, [mrUuid]);
return (
<Grid
columns={{ xs: 1, sm: 2 }}
component="form"
container
onSubmit={(event) => {
event.preventDefault();
formik.submitForm();
}}
spacing="1em"
>
<Grid item xs={1}>
<UncontrolledInput
input={
<OutlinedInputWithLabel
id={nameChain}
label="Recipient name"
name={nameChain}
onChange={handleChange}
required
value={formik.values[mrUuid].name}
/>
}
/>
</Grid>
<Grid item xs={1}>
<UncontrolledInput
input={
<OutlinedInputWithLabel
id={emailChain}
label="Recipient email"
name={emailChain}
onChange={handleChange}
required
value={formik.values[mrUuid].email}
/>
}
/>
</Grid>
<Grid item xs={1}>
<UncontrolledInput
input={
<SelectWithLabel
id={levelChain}
label="Alert level"
name={levelChain}
onChange={formik.handleChange}
required
selectItems={LEVEL_OPTIONS}
selectProps={{
MenuProps: {
sx: {
[`& .${muiMenuClasses.paper}`]: {
maxWidth: { md: '60%', lg: '40%' },
},
},
},
renderValue: (value) => MAP_TO_LEVEL_LABEL[value],
}}
value={formik.values[mrUuid].level}
/>
}
/>
</Grid>
<Grid item width="100%">
<ManageAlertOverride
alertOverrideTargetOptions={alertOverrideTargetOptions}
formikUtils={formikUtils}
mailRecipientUuid={mrUuid}
/>
</Grid>
<Grid item width="100%">
<MessageGroup count={1} messages={formikErrors} />
</Grid>
<Grid item width="100%">
<ActionGroup
actions={[
{
background: 'blue',
children: previousFormikValues ? 'Update' : 'Add',
disabled: disabledSubmit,
type: 'submit',
},
]}
/>
</Grid>
</Grid>
);
};
export default AddMailRecipientForm;

@ -0,0 +1,117 @@
import { Grid } from '@mui/material';
import { FC, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import Autocomplete from '../Autocomplete';
import FlexBox from '../FlexBox';
import IconButton from '../IconButton';
import SelectWithLabel from '../SelectWithLabel';
import { BodyText, SmallText } from '../Text';
import UncontrolledInput from '../UncontrolledInput';
const LEVEL_OPTIONS: SelectItem<number>[] = [
{ displayValue: 'Critical', value: 1 },
{ displayValue: 'Warning', value: 2 },
{ displayValue: 'Notice', value: 3 },
{ displayValue: 'Info', value: 4 },
];
const AlertOverrideInputGroup: FC<AlertOverrideInputGroupProps> = (props) => {
const {
alertOverrideTargetOptions,
alertOverrideUuid,
mailRecipientUuid: mrUuid,
formikUtils,
} = props;
const aoUuid = useMemo<string>(
() => alertOverrideUuid ?? uuidv4(),
[alertOverrideUuid],
);
const { formik } = formikUtils;
const {
values: { [mrUuid]: mailRecipient },
} = formik;
const {
alertOverrides: { [aoUuid]: alertOverride },
} = mailRecipient;
const overrideChain = useMemo<string>(
() => `${mrUuid}.alertOverrides.${aoUuid}`,
[aoUuid, mrUuid],
);
const targetChain = useMemo<string>(
() => `${overrideChain}.target`,
[overrideChain],
);
const levelChain = useMemo<string>(
() => `${overrideChain}.level`,
[overrideChain],
);
return (
<Grid
alignItems="center"
columns={{ xs: 1, sm: 10 }}
container
justifyContent="stretch"
spacing="1em"
>
<Grid item xs={6}>
<Autocomplete
getOptionLabel={(option) => option.name}
id={targetChain}
isOptionEqualToValue={(option, value) => option.uuid === value.uuid}
label="Target"
noOptionsText="No node or subnode found."
onChange={(event, value) =>
formik.setFieldValue(targetChain, value, true)
}
openOnFocus
options={alertOverrideTargetOptions}
renderOption={(optionProps, option) => (
<li {...optionProps} key={`${option.node}-${option.uuid}`}>
{option.type === 'node' ? (
<FlexBox spacing={0}>
<BodyText inheritColour>{option.name}</BodyText>
<SmallText inheritColour>{option.description}</SmallText>
</FlexBox>
) : (
<BodyText inheritColour paddingLeft=".6em">
{option.name}
</BodyText>
)}
</li>
)}
value={alertOverride.target}
/>
</Grid>
<Grid item flexGrow={1}>
<UncontrolledInput
input={
<SelectWithLabel
id={levelChain}
label="Alert level"
name={levelChain}
onChange={formik.handleChange}
selectItems={LEVEL_OPTIONS}
value={alertOverride.level}
/>
}
/>
</Grid>
<Grid item width="min-content">
<IconButton
mapPreset="delete"
onClick={() => {
formik.setFieldValue(overrideChain, undefined, true);
}}
size="small"
/>
</Grid>
</Grid>
);
};
export default AlertOverrideInputGroup;

@ -0,0 +1,9 @@
import { FC } from 'react';
import AddMailRecipientForm from './AddMailRecipientForm';
const EditMailRecipientForm: FC<EditMailRecipientFormProps> = (props) => (
<AddMailRecipientForm {...props} />
);
export default EditMailRecipientForm;

@ -0,0 +1,58 @@
import { FC } from 'react';
import { v4 as uuidv4 } from 'uuid';
import AlertOverrideInputGroup from './AlertOverrideInputGroup';
import List from '../List';
import useChecklist from '../../hooks/useChecklist';
const ManageAlertOverride: FC<ManageAlertOverrideProps> = (props) => {
const {
alertOverrideTargetOptions,
formikUtils,
mailRecipientUuid: mrUuid,
} = props;
const { formik } = formikUtils;
const {
values: { [mrUuid]: mailRecipient },
} = formik;
const { alertOverrides } = mailRecipient;
const { hasChecks } = useChecklist(alertOverrides);
return (
<List
allowAddItem
disableDelete={!hasChecks}
edit
header="Alert override rules"
listEmpty="No alert overrides(s)"
listItems={alertOverrides}
onAdd={() => {
const aoUuid = uuidv4();
formik.setValues((previous) => {
const current = { ...previous };
current[mrUuid].alertOverrides[aoUuid] = {
level: 2,
target: null,
uuid: aoUuid,
};
return current;
});
}}
renderListItem={(uuid) => (
<AlertOverrideInputGroup
alertOverrideTargetOptions={alertOverrideTargetOptions}
alertOverrideUuid={uuid}
formikUtils={formikUtils}
mailRecipientUuid={mrUuid}
/>
)}
/>
);
};
export default ManageAlertOverride;

@ -0,0 +1,160 @@
import { FC, useMemo, useState } from 'react';
import AddMailRecipientForm from './AddMailRecipientForm';
import { toAnvilOverviewList } from '../../lib/api_converters';
import CrudList from '../CrudList';
import EditMailRecipientForm from './EditMailRecipientForm';
import { BodyText } from '../Text';
import useActiveFetch from '../../hooks/useActiveFetch';
import useFetch from '../../hooks/useFetch';
const ManageMailRecipient: FC = () => {
const [alertOverrides, setAlertOverrides] = useState<
APIAlertOverrideOverviewList | undefined
>();
const { altData: nodes, loading: loadingNodes } = useFetch<
APIAnvilOverviewArray,
APIAnvilOverviewList
>('/anvil', { mod: toAnvilOverviewList });
const alertOverrideTargetOptions = useMemo<AlertOverrideTarget[] | undefined>(
() =>
nodes &&
Object.values(nodes)
.sort((a, b) => a.name.localeCompare(b.name))
.reduce<AlertOverrideTarget[]>((options, node) => {
options.push({
description: node.description,
name: node.name,
node: node.uuid,
type: 'node',
uuid: node.uuid,
});
Object.values(node.hosts)
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((subnode) => {
if (subnode.type === 'dr') return;
options.push({
name: subnode.name,
node: node.uuid,
type: 'subnode',
uuid: subnode.uuid,
});
});
return options;
}, []),
[nodes],
);
const { fetch: getAlertOverrides, loading: loadingAlertOverrides } =
useActiveFetch<APIAlertOverrideOverviewList>({
onData: (data) => setAlertOverrides(data),
url: '/alert-override',
});
const formikAlertOverrides = useMemo<
AlertOverrideFormikValues | undefined
>(() => {
if (!alertOverrides) return undefined;
const groups: Record<string, number> = {};
return Object.values(alertOverrides).reduce<AlertOverrideFormikValues>(
(previous, value) => {
const { level, node, subnode, uuid } = value;
groups[node.uuid] = groups[node.uuid] ? groups[node.uuid] + 1 : 1;
previous[uuid] = {
level,
target:
groups[node.uuid] > 1
? {
name: node.name,
node: node.uuid,
type: 'node',
uuid: node.uuid,
}
: {
name: subnode.name,
node: node.uuid,
type: 'subnode',
uuid: subnode.uuid,
},
uuid,
};
return previous;
},
{},
);
}, [alertOverrides]);
return (
<>
<CrudList<APIMailRecipientOverview, APIMailRecipientDetail>
addHeader="Add mail recipient"
editHeader={(entry) => `Update ${entry?.name}`}
entriesUrl="/mail-recipient"
getAddLoading={(previous) => previous || loadingNodes}
getDeleteErrorMessage={({ children, ...rest }) => ({
...rest,
children: <>Failed to delete mail recipient(s). {children}</>,
})}
getDeleteHeader={(count) =>
`Delete the following ${count} mail recipient(s)?`
}
getDeleteSuccessMessage={() => ({
children: <>Successfully deleted mail recipient(s).</>,
})}
getEditLoading={(previous) => previous || loadingAlertOverrides}
onItemClick={(base, ...args) => {
const [, mailRecipientUuid] = args;
base(...args);
getAlertOverrides(undefined, {
params: { 'mail-recipient': mailRecipientUuid },
});
}}
renderAddForm={(tools) =>
alertOverrideTargetOptions && (
<AddMailRecipientForm
alertOverrideTargetOptions={alertOverrideTargetOptions}
tools={tools}
/>
)
}
renderDeleteItem={(mailRecipientList, { key }) => {
const mr = mailRecipientList?.[key];
return <BodyText>{mr?.name}</BodyText>;
}}
renderEditForm={(tools, mailRecipient) =>
alertOverrideTargetOptions &&
mailRecipient &&
formikAlertOverrides && (
<EditMailRecipientForm
alertOverrideTargetOptions={alertOverrideTargetOptions}
mailRecipientUuid={mailRecipient.uuid}
previousFormikValues={{
[mailRecipient.uuid]: {
alertOverrides: formikAlertOverrides,
...mailRecipient,
},
}}
tools={tools}
/>
)
}
renderListItem={(uuid, { name }) => <BodyText>{name}</BodyText>}
/>
</>
);
};
export default ManageMailRecipient;

@ -0,0 +1,4 @@
import AddMailRecipientForm from './AddMailRecipientForm';
import ManageMailRecipient from './ManageMailRecipient';
export { AddMailRecipientForm, ManageMailRecipient };

@ -0,0 +1,33 @@
import * as yup from 'yup';
import buildYupDynamicObject from '../../lib/buildYupDynamicObject';
const alertLevelSchema = yup.number().oneOf([1, 2, 3, 4]);
const alertOverrideSchema = yup.object({
level: alertLevelSchema.required(),
target: yup.object({
type: yup.string().oneOf(['node', 'subnode']).required(),
uuid: yup.string().uuid().required(),
}),
uuid: yup.string().uuid().required(),
});
const alertOverrideListSchema = yup.lazy((entries) =>
yup.object(buildYupDynamicObject(entries, alertOverrideSchema)),
);
const mailRecipientSchema = yup.object({
alertOverrides: alertOverrideListSchema,
email: yup.string().email().required(),
language: yup.string().oneOf(['en_CA']).optional(),
level: alertLevelSchema.required(),
name: yup.string().required(),
uuid: yup.string().uuid().required(),
});
const mailRecipientListSchema = yup.lazy((entries) =>
yup.object(buildYupDynamicObject(entries, mailRecipientSchema)),
);
export default mailRecipientListSchema;

@ -0,0 +1,11 @@
type APIAlertOverrideOverview = {
level: number;
mailRecipient: APIMailRecipientOverview;
node: { name: string; uuid: string };
subnode: { name: string; short: string; uuid: string };
uuid: string;
};
type APIAlertOverrideOverviewList = {
[uuid: string]: APIAlertOverrideOverview;
};

@ -0,0 +1,11 @@
type APIMailRecipientOverview = { name: string; uuid: string };
type APIMailRecipientOverviewList = {
[uuid: string]: APIMailRecipientOverview;
};
type APIMailRecipientDetail = APIMailRecipientOverview & {
email: string;
language: string;
level: number;
};

@ -0,0 +1,61 @@
type AlertOverrideTarget = {
description?: string;
name: string;
node: string;
type: 'node' | 'subnode';
uuid: string;
};
type AlertOverrideFormikAlertOverride = {
level: number;
target: AlertOverrideTarget | null;
uuid: string;
};
type AlertOverrideFormikValues = {
[uuid: string]: AlertOverrideFormikAlertOverride;
};
type MailRecipientFormikMailRecipient = APIMailRecipientDetail & {
alertOverrides: AlertOverrideFormikValues;
};
type MailRecipientFormikValues = {
[uuid: string]: MailRecipientFormikMailRecipient;
};
/** AddMailRecipientForm */
type AddMailRecipientFormOptionalProps = {
mailRecipientUuid?: string;
previousFormikValues?: MailRecipientFormikValues;
};
type AddMailRecipientFormProps = AddMailRecipientFormOptionalProps & {
alertOverrideTargetOptions: AlertOverrideTarget[];
tools: CrudListFormTools;
};
/** EditMailRecipientForm */
type EditMailRecipientFormProps = Required<AddMailRecipientFormProps>;
/** ManageAlertOverride */
type ManageAlertOverrideProps = Required<
Pick<
AddMailRecipientFormProps,
'alertOverrideTargetOptions' | 'mailRecipientUuid'
>
> & {
formikUtils: FormikUtils<MailRecipientFormikValues>;
};
/** AlertOverrideInputGroup */
type AlertOverrideInputGroupOptionalProps = {
alertOverrideUuid?: string;
};
type AlertOverrideInputGroupProps = AlertOverrideInputGroupOptionalProps &
ManageAlertOverrideProps;
Loading…
Cancel
Save