Cleanup typing for server and expose more useful endpoint and transformer logic

This commit is contained in:
Dane Everitt 2021-10-09 12:02:32 -07:00
parent 3afd8b9f03
commit 00d0f49ede
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
11 changed files with 257 additions and 90 deletions

View file

@ -43,6 +43,10 @@ rules:
array-bracket-spacing: array-bracket-spacing:
- warn - warn
- always - always
"@typescript-eslint/no-unused-vars":
- warn
- argsIgnorePattern: '^_'
varsIgnorePattern: '^_'
# Remove errors for not having newlines between operands of ternary expressions https://eslint.org/docs/rules/multiline-ternary # Remove errors for not having newlines between operands of ternary expressions https://eslint.org/docs/rules/multiline-ternary
multiline-ternary: 0 multiline-ternary: 0
"react-hooks/rules-of-hooks": "react-hooks/rules-of-hooks":

View file

@ -1,5 +1,30 @@
import { createContext } from 'react'; import { createContext } from 'react';
export interface Model {
relationships: Record<string, unknown>;
}
export type UUID = string;
/**
* Marks the provided relationships keys as present in the given model
* rather than being optional to improve typing responses.
*/
export type WithRelationships<M extends Model, R extends string> = Omit<M, 'relationships'> & {
relationships: Omit<M['relationships'], keyof R> & {
[K in R]: NonNullable<M['relationships'][K]>;
}
}
/**
* Helper function that just returns the model you pass in, but types the model
* such that TypeScript understands the relationships on it. This is just to help
* reduce the amount of duplicated type casting all over the codebase.
*/
export const withRelationships = <M extends Model, R extends string> (model: M, ..._keys: R[]) => {
return model as unknown as WithRelationships<M, R>;
};
export interface ListContext<T> { export interface ListContext<T> {
page: number; page: number;
setPage: (page: ((p: number) => number) | number) => void; setPage: (page: ((p: number) => number) | number) => void;

View file

@ -0,0 +1,95 @@
import { Allocation } from '@/api/admin/nodes/getAllocations';
import { Egg } from '@/api/admin/eggs/getEgg';
import { User } from '@/api/admin/users/getUsers';
import { Node } from '@/api/admin/nodes/getNodes';
import { rawDataToServer, ServerVariable } from '@/api/admin/servers/getServers';
import useSWR, { SWRResponse } from 'swr';
import { AxiosError } from 'axios';
import { useRouteMatch } from 'react-router-dom';
import http from '@/api/http';
import { Model, UUID, withRelationships, WithRelationships } from '@/api/admin/index';
import { AdminTransformers } from '@/api/admin/transformers';
/**
* Defines the limits for a server that exists on the Panel.
*/
interface ServerLimits {
memory: number;
swap: number;
disk: number;
io: number;
cpu: number;
threads: string | null;
oomDisabled: boolean;
}
/**
* Defines a single server instance that is returned from the Panel's admin
* API endpoints.
*/
export interface Server extends Model {
id: number;
uuid: UUID;
externalId: string | null;
identifier: string;
name: string;
description: string;
status: string;
userId: number;
nodeId: number;
allocationId: number;
eggId: number;
limits: ServerLimits;
featureLimits: {
databases: number;
allocations: number;
backups: number;
};
container: {
startup: string;
image: string;
environment: Record<string, string>;
};
createdAt: Date;
updatedAt: Date;
relationships: {
allocations?: Allocation[];
egg?: Egg;
node?: Node;
user?: User;
variables?: ServerVariable[];
};
}
/**
* A standard API response with the minimum viable details for the frontend
* to correctly render a server.
*/
type LoadedServer = WithRelationships<Server, 'allocations' | 'user' | 'node'>;
/**
* Fetches a server from the API and ensures that the allocations, user, and
* node data is loaded.
*/
export const getServer = async (id: number | string): Promise<LoadedServer> => {
const { data } = await http.get(`/api/application/servers/${id}`, {
params: {
includes: [ 'allocations', 'user', 'node' ],
},
});
return withRelationships(AdminTransformers.toServer(data), 'allocations', 'user', 'node');
};
/**
* Returns an SWR instance by automatically loading in the server for the currently
* loaded route match in the admin area.
*/
export const useServerFromRoute = (): SWRResponse<LoadedServer, AxiosError> => {
const { params } = useRouteMatch<{ id: string }>();
return useSWR(`/api/application/servers/${params.id}`, async () => getServer(params.id), {
revalidateOnMount: false,
revalidateOnFocus: false,
});
};

View file

@ -23,7 +23,7 @@ export interface ServerVariable {
updatedAt: Date; updatedAt: Date;
} }
const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({ export const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({
id: attributes.id, id: attributes.id,
eggId: attributes.egg_id, eggId: attributes.egg_id,
name: attributes.name, name: attributes.name,

View file

@ -0,0 +1,56 @@
/* eslint-disable camelcase */
import { Server } from '@/api/admin/server';
import { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToAllocation } from '@/api/admin/nodes/getAllocations';
import { rawDataToEgg } from '@/api/admin/eggs/getEgg';
import { rawDataToNode } from '@/api/admin/nodes/getNodes';
import { rawDataToUser } from '@/api/admin/users/getUsers';
import { rawDataToServerVariable } from '@/api/admin/servers/getServers';
const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list';
function transform<T, M = undefined> (data: undefined, transformer: (callback: FractalResponseData) => T, missing?: M): undefined;
function transform<T, M> (data: FractalResponseData | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T | M | undefined;
function transform<T, M> (data: FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T[] | undefined;
function transform<T> (data: FractalResponseData | FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing = undefined) {
if (data === undefined) return undefined;
if (isList(data)) {
return data.data.map(transformer);
}
return !data ? missing : transformer(data);
}
export class AdminTransformers {
static toServer = ({ attributes }: FractalResponseData): Server => {
const { oom_disabled, ...limits } = attributes.limits;
const { allocations, egg, node, user, variables } = attributes.relationships || {};
return {
id: attributes.id,
uuid: attributes.uuid,
externalId: attributes.external_id,
identifier: attributes.identifier,
name: attributes.name,
description: attributes.description,
status: attributes.status,
userId: attributes.owner_id,
nodeId: attributes.node_id,
allocationId: attributes.allocation_id,
eggId: attributes.egg_id,
limits: { ...limits, oomDisabled: oom_disabled },
featureLimits: attributes.feature_limits,
container: attributes.container,
createdAt: new Date(attributes.created_at),
updatedAt: new Date(attributes.updated_at),
relationships: {
allocations: transform(allocations as FractalResponseList | undefined, rawDataToAllocation),
egg: transform(egg as FractalResponseData | undefined, rawDataToEgg),
node: transform(node as FractalResponseData | undefined, rawDataToNode),
user: transform(user as FractalResponseData | undefined, rawDataToUser),
variables: transform(variables as FractalResponseList | undefined, rawDataToServerVariable),
},
};
};
}

View file

@ -1,19 +0,0 @@
import useSWR, { SWRResponse } from 'swr';
import http from '@/api/http';
import { rawDataToServer, Server } from '@/api/admin/servers/getServers';
import { useRouteMatch } from 'react-router-dom';
import { AxiosError } from 'axios';
export default (): SWRResponse<Server, AxiosError> => {
const { params } = useRouteMatch<{ id: string }>();
return useSWR(`/api/application/servers/${params.id}`, async (key) => {
const { data } = await http.get(key, {
params: {
includes: [ 'allocations', 'user', 'variables' ],
},
});
return rawDataToServer(data);
}, { revalidateOnMount: false, revalidateOnFocus: false });
};

View file

@ -11,8 +11,8 @@ import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation';
import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer'; import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer';
import getServerDetails from '@/api/swr/admin/getServerDetails';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { useServerFromRoute } from '@/api/admin/server';
export const ServerIncludes = [ 'allocations', 'user', 'variables' ]; export const ServerIncludes = [ 'allocations', 'user', 'variables' ];
@ -34,7 +34,7 @@ const ServerRouter = () => {
const match = useRouteMatch<{ id?: string }>(); const match = useRouteMatch<{ id?: string }>();
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data: server, error, isValidating, mutate } = getServerDetails(); const { data: server, error, isValidating, mutate } = useServerFromRoute();
useEffect(() => { useEffect(() => {
mutate(); mutate();

View file

@ -1,10 +1,6 @@
import getAllocations from '@/api/admin/nodes/getAllocations';
import { Server } from '@/api/admin/servers/getServers'; import { Server } from '@/api/admin/servers/getServers';
import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton'; import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton';
import Label from '@/components/elements/Label'; import { faBalanceScale } from '@fortawesome/free-solid-svg-icons';
import Select from '@/components/elements/Select';
import SelectField, { AsyncSelectField, Option } from '@/components/elements/SelectField';
import { faBalanceScale, faConciergeBell, faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import React from 'react'; import React from 'react';
import AdminBox from '@/components/admin/AdminBox'; import AdminBox from '@/components/admin/AdminBox';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -21,56 +17,7 @@ import Button from '@/components/elements/Button';
import FormikSwitch from '@/components/elements/FormikSwitch'; import FormikSwitch from '@/components/elements/FormikSwitch';
import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox'; import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox';
import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox'; import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox';
import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox';
export function ServerAllocationsContainer ({ server }: { server: Server }) {
const { isSubmitting } = useFormikContext();
const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => {
const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' });
callback(allocations.map(a => {
return { value: a.id.toString(), label: a.getDisplayText() };
}));
};
return (
<AdminBox icon={faNetworkWired} title={'Networking'} css={tw`relative w-full`}>
<SpinnerOverlay visible={isSubmitting}/>
<div css={tw`mb-6`}>
<Label>Primary Allocation</Label>
<Select
id={'allocationId'}
name={'allocationId'}
>
{server.relations?.allocations?.map(a => (
<option key={a.id} value={a.id}>{a.getDisplayText()}</option>
))}
</Select>
</div>
<AsyncSelectField
id={'addAllocations'}
name={'addAllocations'}
label={'Add Allocations'}
loadOptions={loadOptions}
isMulti
css={tw`mb-6`}
/>
<SelectField
id={'removeAllocations'}
name={'removeAllocations'}
label={'Remove Allocations'}
options={server.relations?.allocations?.map(a => {
return { value: a.id.toString(), label: a.getDisplayText() };
}) || []}
isMulti
isSearchable
css={tw`mb-2`}
/>
</AdminBox>
);
}
export function ServerResourceContainer () { export function ServerResourceContainer () {
const { isSubmitting } = useFormikContext(); const { isSubmitting } = useFormikContext();
@ -160,7 +107,10 @@ export function ServerResourceContainer () {
export default function ServerSettingsContainer2 ({ server }: { server: Server }) { export default function ServerSettingsContainer2 ({ server }: { server: Server }) {
const history = useHistory(); const history = useHistory();
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const {
clearFlashes,
clearAndAddHttpError,
} = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const setServer = Context.useStoreActions(actions => actions.setServer); const setServer = Context.useStoreActions(actions => actions.setServer);
@ -216,8 +166,7 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server }
addAllocations: [] as number[], addAllocations: [] as number[],
removeAllocations: [] as number[], removeAllocations: [] as number[],
}} }}
validationSchema={object().shape({ validationSchema={object().shape({})}
})}
> >
{({ isSubmitting, isValid }) => ( {({ isSubmitting, isValid }) => (
<Form> <Form>
@ -225,9 +174,8 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server }
<div css={tw`grid grid-cols-1 gap-y-6`}> <div css={tw`grid grid-cols-1 gap-y-6`}>
<BaseSettingsBox/> <BaseSettingsBox/>
<FeatureLimitsBox/> <FeatureLimitsBox/>
<ServerAllocationsContainer server={server}/> <NetworkingBox/>
</div> </div>
<div css={tw`flex flex-col`}> <div css={tw`flex flex-col`}>
<ServerResourceContainer/> <ServerResourceContainer/>
@ -237,7 +185,12 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server }
serverId={server?.id} serverId={server?.id}
onDeleted={() => history.push('/admin/servers')} onDeleted={() => history.push('/admin/servers')}
/> />
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}> <Button
type="submit"
size="small"
css={tw`ml-auto`}
disabled={isSubmitting || !isValid}
>
Save Changes Save Changes
</Button> </Button>
</div> </div>

View file

@ -5,10 +5,10 @@ import AdminBox from '@/components/admin/AdminBox';
import { faCogs } from '@fortawesome/free-solid-svg-icons'; import { faCogs } from '@fortawesome/free-solid-svg-icons';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import OwnerSelect from '@/components/admin/servers/OwnerSelect'; import OwnerSelect from '@/components/admin/servers/OwnerSelect';
import getServerDetails from '@/api/swr/admin/getServerDetails'; import { useServerFromRoute } from '@/api/admin/server';
export default () => { export default () => {
const { data: server } = getServerDetails(); const { data: server } = useServerFromRoute();
const { isSubmitting } = useFormikContext(); const { isSubmitting } = useFormikContext();
if (!server) return null; if (!server) return null;
@ -18,7 +18,7 @@ export default () => {
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}> <div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>
<Field id={'name'} name={'name'} label={'Server Name'} type={'text'}/> <Field id={'name'} name={'name'} label={'Server Name'} type={'text'}/>
<Field id={'externalId'} name={'externalId'} label={'External Identifier'} type={'text'}/> <Field id={'externalId'} name={'externalId'} label={'External Identifier'} type={'text'}/>
<OwnerSelect selected={server.relations.user || null}/> <OwnerSelect selected={server.relationships.user}/>
</div> </div>
</AdminBox> </AdminBox>
); );

View file

@ -4,14 +4,10 @@ import AdminBox from '@/components/admin/AdminBox';
import { faConciergeBell } from '@fortawesome/free-solid-svg-icons'; import { faConciergeBell } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import getServerDetails from '@/api/swr/admin/getServerDetails';
export default () => { export default () => {
const { data: server } = getServerDetails();
const { isSubmitting } = useFormikContext(); const { isSubmitting } = useFormikContext();
if (!server) return null;
return ( return (
<AdminBox icon={faConciergeBell} title={'Feature Limits'} isLoading={isSubmitting}> <AdminBox icon={faConciergeBell} title={'Feature Limits'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}> <div css={tw`grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6`}>

View file

@ -0,0 +1,57 @@
import React from 'react';
import { useFormikContext } from 'formik';
import SelectField, { AsyncSelectField, Option } from '@/components/elements/SelectField';
import getAllocations from '@/api/admin/nodes/getAllocations';
import AdminBox from '@/components/admin/AdminBox';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
import Label from '@/components/elements/Label';
import Select from '@/components/elements/Select';
import { useServerFromRoute } from '@/api/admin/server';
export default () => {
const { isSubmitting } = useFormikContext();
const { data: server } = useServerFromRoute();
if (!server) return null;
const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => {
const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' });
callback(allocations.map(a => {
return { value: a.id.toString(), label: a.getDisplayText() };
}));
};
return (
<AdminBox icon={faNetworkWired} title={'Networking'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 gap-4 lg:gap-6`}>
<div>
<Label htmlFor={'allocationId'}>Primary Allocation</Label>
<Select id={'allocationId'} name={'allocationId'}>
{server.relationships.allocations?.map(a => (
<option key={a.id} value={a.id}>{a.getDisplayText()}</option>
))}
</Select>
</div>
<AsyncSelectField
id={'addAllocations'}
name={'addAllocations'}
label={'Add Allocations'}
loadOptions={loadOptions}
isMulti
/>
<SelectField
id={'removeAllocations'}
name={'removeAllocations'}
label={'Remove Allocations'}
options={server.relationships.allocations?.map(a => {
return { value: a.id.toString(), label: a.getDisplayText() };
}) || []}
isMulti
isSearchable
/>
</div>
</AdminBox>
);
};