diff --git a/.eslintrc.yml b/.eslintrc.yml index a4630dcb8..1c3adcb14 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -43,6 +43,10 @@ rules: array-bracket-spacing: - warn - 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 multiline-ternary: 0 "react-hooks/rules-of-hooks": diff --git a/resources/scripts/api/admin/index.ts b/resources/scripts/api/admin/index.ts index 54161f2b0..6d57288c5 100644 --- a/resources/scripts/api/admin/index.ts +++ b/resources/scripts/api/admin/index.ts @@ -1,5 +1,30 @@ import { createContext } from 'react'; +export interface Model { + relationships: Record; +} + +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 = Omit & { + relationships: Omit & { + [K in R]: NonNullable; + } +} + +/** + * 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 = (model: M, ..._keys: R[]) => { + return model as unknown as WithRelationships; +}; + export interface ListContext { page: number; setPage: (page: ((p: number) => number) | number) => void; diff --git a/resources/scripts/api/admin/server.ts b/resources/scripts/api/admin/server.ts new file mode 100644 index 000000000..fd45aa0bb --- /dev/null +++ b/resources/scripts/api/admin/server.ts @@ -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; + }; + 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; + +/** + * 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 => { + 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 => { + const { params } = useRouteMatch<{ id: string }>(); + + return useSWR(`/api/application/servers/${params.id}`, async () => getServer(params.id), { + revalidateOnMount: false, + revalidateOnFocus: false, + }); +}; diff --git a/resources/scripts/api/admin/servers/getServers.ts b/resources/scripts/api/admin/servers/getServers.ts index 8f40a30f2..e34e0a566 100644 --- a/resources/scripts/api/admin/servers/getServers.ts +++ b/resources/scripts/api/admin/servers/getServers.ts @@ -23,7 +23,7 @@ export interface ServerVariable { updatedAt: Date; } -const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({ +export const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({ id: attributes.id, eggId: attributes.egg_id, name: attributes.name, diff --git a/resources/scripts/api/admin/transformers.ts b/resources/scripts/api/admin/transformers.ts new file mode 100644 index 000000000..d3d21a1ab --- /dev/null +++ b/resources/scripts/api/admin/transformers.ts @@ -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 (data: undefined, transformer: (callback: FractalResponseData) => T, missing?: M): undefined; +function transform (data: FractalResponseData | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T | M | undefined; +function transform (data: FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T[] | undefined; +function transform (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), + }, + }; + }; +} diff --git a/resources/scripts/api/swr/admin/getServerDetails.ts b/resources/scripts/api/swr/admin/getServerDetails.ts deleted file mode 100644 index ce88110f3..000000000 --- a/resources/scripts/api/swr/admin/getServerDetails.ts +++ /dev/null @@ -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 => { - 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 }); -}; diff --git a/resources/scripts/components/admin/servers/ServerRouter.tsx b/resources/scripts/components/admin/servers/ServerRouter.tsx index 0733f1536..2e08bc4eb 100644 --- a/resources/scripts/components/admin/servers/ServerRouter.tsx +++ b/resources/scripts/components/admin/servers/ServerRouter.tsx @@ -11,8 +11,8 @@ import Spinner from '@/components/elements/Spinner'; import FlashMessageRender from '@/components/FlashMessageRender'; import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer'; -import getServerDetails from '@/api/swr/admin/getServerDetails'; import useFlash from '@/plugins/useFlash'; +import { useServerFromRoute } from '@/api/admin/server'; export const ServerIncludes = [ 'allocations', 'user', 'variables' ]; @@ -34,7 +34,7 @@ const ServerRouter = () => { const match = useRouteMatch<{ id?: string }>(); const { clearFlashes, clearAndAddHttpError } = useFlash(); - const { data: server, error, isValidating, mutate } = getServerDetails(); + const { data: server, error, isValidating, mutate } = useServerFromRoute(); useEffect(() => { mutate(); diff --git a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx index b3a25ee40..38f59693a 100644 --- a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx @@ -1,10 +1,6 @@ -import getAllocations from '@/api/admin/nodes/getAllocations'; import { Server } from '@/api/admin/servers/getServers'; import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton'; -import Label from '@/components/elements/Label'; -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 { faBalanceScale } from '@fortawesome/free-solid-svg-icons'; import React from 'react'; import AdminBox from '@/components/admin/AdminBox'; import { useHistory } from 'react-router-dom'; @@ -21,56 +17,7 @@ import Button from '@/components/elements/Button'; import FormikSwitch from '@/components/elements/FormikSwitch'; import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox'; import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox'; - -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 ( - - - -
- - -
- - - - { - return { value: a.id.toString(), label: a.getDisplayText() }; - }) || []} - isMulti - isSearchable - css={tw`mb-2`} - /> -
- ); -} +import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox'; export function ServerResourceContainer () { const { isSubmitting } = useFormikContext(); @@ -160,7 +107,10 @@ export function ServerResourceContainer () { export default function ServerSettingsContainer2 ({ server }: { server: Server }) { const history = useHistory(); - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); + const { + clearFlashes, + clearAndAddHttpError, + } = useStoreActions((actions: Actions) => actions.flashes); const setServer = Context.useStoreActions(actions => actions.setServer); @@ -216,8 +166,7 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } addAllocations: [] as number[], removeAllocations: [] as number[], }} - validationSchema={object().shape({ - })} + validationSchema={object().shape({})} > {({ isSubmitting, isValid }) => (
@@ -225,9 +174,8 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server }
- +
-
@@ -237,7 +185,12 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } serverId={server?.id} onDeleted={() => history.push('/admin/servers')} /> -
diff --git a/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx b/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx index edca72b63..898bd62da 100644 --- a/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx +++ b/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx @@ -5,10 +5,10 @@ import AdminBox from '@/components/admin/AdminBox'; import { faCogs } from '@fortawesome/free-solid-svg-icons'; import Field from '@/components/elements/Field'; import OwnerSelect from '@/components/admin/servers/OwnerSelect'; -import getServerDetails from '@/api/swr/admin/getServerDetails'; +import { useServerFromRoute } from '@/api/admin/server'; export default () => { - const { data: server } = getServerDetails(); + const { data: server } = useServerFromRoute(); const { isSubmitting } = useFormikContext(); if (!server) return null; @@ -18,7 +18,7 @@ export default () => {
- +
); diff --git a/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx b/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx index d6597dd13..ea312fb81 100644 --- a/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx +++ b/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx @@ -4,14 +4,10 @@ import AdminBox from '@/components/admin/AdminBox'; import { faConciergeBell } from '@fortawesome/free-solid-svg-icons'; import tw from 'twin.macro'; import Field from '@/components/elements/Field'; -import getServerDetails from '@/api/swr/admin/getServerDetails'; export default () => { - const { data: server } = getServerDetails(); const { isSubmitting } = useFormikContext(); - if (!server) return null; - return (
diff --git a/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx b/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx new file mode 100644 index 000000000..864d8fa9b --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx @@ -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 ( + +
+
+ + +
+ + { + return { value: a.id.toString(), label: a.getDisplayText() }; + }) || []} + isMulti + isSearchable + /> +
+
+ ); +};