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/egg.ts b/resources/scripts/api/admin/egg.ts new file mode 100644 index 000000000..fb7e52ed8 --- /dev/null +++ b/resources/scripts/api/admin/egg.ts @@ -0,0 +1,70 @@ +import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index'; +import { Nest } from '@/api/admin/nest'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export interface Egg extends Model { + id: number; + uuid: UUID; + nestId: number; + author: string; + name: string; + description: string | null; + features: string[] | null; + dockerImages: string[]; + configFiles: Record | null; + configStartup: Record | null; + configStop: string | null; + configFrom: number | null; + startup: string; + scriptContainer: string; + copyScriptFrom: number | null; + scriptEntry: string; + scriptIsPrivileged: boolean; + scriptInstall: string | null; + createdAt: Date; + updatedAt: Date; + relationships: { + nest?: Nest; + variables?: EggVariable[]; + }; +} + +export interface EggVariable extends Model { + id: number; + eggId: number; + name: string; + description: string; + environmentVariable: string; + defaultValue: string; + isUserViewable: boolean; + isUserEditable: boolean; + isRequired: boolean; + rules: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * Gets a single egg from the database and returns it. + */ +export const getEgg = async (id: number | string): Promise> => { + const { data } = await http.get(`/api/application/eggs/${id}`, { + params: { + include: [ 'nest', 'variables' ], + }, + }); + + return withRelationships(AdminTransformers.toEgg(data), 'nest', 'variables'); +}; + +export const searchEggs = async (nestId: number, params: QueryBuilderParams<'name'>): Promise[]> => { + const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { + params: { + ...withQueryBuilderParams(params), + include: [ 'variables' ], + }, + }); + + return data.data.map(AdminTransformers.toEgg); +}; diff --git a/resources/scripts/api/admin/index.ts b/resources/scripts/api/admin/index.ts index 54161f2b0..014a207a7 100644 --- a/resources/scripts/api/admin/index.ts +++ b/resources/scripts/api/admin/index.ts @@ -1,5 +1,38 @@ 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 type that allows you to infer the type of an object by giving + * it the specific API request function with a return type. For example: + * + * type EggT = InferModel; + */ +export type InferModel any> = ReturnType extends Promise ? U : T; + +/** + * 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/location.ts b/resources/scripts/api/admin/location.ts new file mode 100644 index 000000000..82ff394f8 --- /dev/null +++ b/resources/scripts/api/admin/location.ts @@ -0,0 +1,13 @@ +import { Model } from '@/api/admin/index'; +import { Node } from '@/api/admin/node'; + +export interface Location extends Model { + id: number; + short: string; + long: string; + createdAt: Date; + updatedAt: Date; + relationships: { + nodes?: Node[]; + }; +} diff --git a/resources/scripts/api/admin/nest.ts b/resources/scripts/api/admin/nest.ts new file mode 100644 index 000000000..c808637fc --- /dev/null +++ b/resources/scripts/api/admin/nest.ts @@ -0,0 +1,25 @@ +import { Model, UUID } from '@/api/admin/index'; +import { Egg } from '@/api/admin/egg'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export interface Nest extends Model { + id: number; + uuid: UUID; + author: string; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + relationships: { + eggs?: Egg[]; + }; +} + +export const searchNests = async (params: QueryBuilderParams<'name'>): Promise => { + const { data } = await http.get('/api/application/nests', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toNest); +}; diff --git a/resources/scripts/api/admin/nests/searchEggs.ts b/resources/scripts/api/admin/nests/searchEggs.ts deleted file mode 100644 index 8bb0f3185..000000000 --- a/resources/scripts/api/admin/nests/searchEggs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import http from '@/api/http'; -import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; - -interface Filters { - name?: string; -} - -export default (nestId: number, filters?: Filters, include: string[] = []): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get(`/api/application/nests/${nestId}/eggs`, { params: { include: include.join(','), ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToEgg) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/nests/searchNests.ts b/resources/scripts/api/admin/nests/searchNests.ts deleted file mode 100644 index 65cc41f36..000000000 --- a/resources/scripts/api/admin/nests/searchNests.ts +++ /dev/null @@ -1,24 +0,0 @@ -import http from '@/api/http'; -import { Nest, rawDataToNest } from '@/api/admin/nests/getNests'; - -interface Filters { - name?: string; -} - -export default (filters?: Filters): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get('/api/application/nests', { params: { ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToNest) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/node.ts b/resources/scripts/api/admin/node.ts new file mode 100644 index 000000000..3320e746a --- /dev/null +++ b/resources/scripts/api/admin/node.ts @@ -0,0 +1,68 @@ +import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index'; +import { Location } from '@/api/admin/location'; +import http from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; +import { Server } from '@/api/admin/server'; + +interface NodePorts { + http: { + listen: number; + public: number; + }; + sftp: { + listen: number; + public: number; + }; +} + +export interface Allocation extends Model { + id: number; + ip: string; + port: number; + alias: string | null; + isAssigned: boolean; + relationships: { + node?: Node; + server?: Server | null; + }; + getDisplayText(): string; +} + +export interface Node extends Model { + id: number; + uuid: UUID; + isPublic: boolean; + locationId: number; + databaseHostId: number; + name: string; + description: string | null; + fqdn: string; + ports: NodePorts; + scheme: 'http' | 'https'; + isBehindProxy: boolean; + isMaintenanceMode: boolean; + memory: number; + memoryOverallocate: number; + disk: number; + diskOverallocate: number; + uploadSize: number; + daemonBase: string; + createdAt: Date; + updatedAt: Date; + relationships: { + location?: Location; + }; +} + +/** + * Gets a single node and returns it. + */ +export const getNode = async (id: string | number): Promise> => { + const { data } = await http.get(`/api/application/nodes/${id}`, { + params: { + include: [ 'location' ], + }, + }); + + return withRelationships(AdminTransformers.toNode(data.data), 'location'); +}; diff --git a/resources/scripts/api/admin/server.ts b/resources/scripts/api/admin/server.ts new file mode 100644 index 000000000..368175543 --- /dev/null +++ b/resources/scripts/api/admin/server.ts @@ -0,0 +1,100 @@ +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'; +import { Allocation, Node } from '@/api/admin/node'; +import { User } from '@/api/admin/user'; +import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; + +/** + * 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; +} + +export interface ServerVariable extends EggVariable { + serverValue: string; +} + +/** + * 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; + nestId: number; + limits: ServerLimits; + featureLimits: { + databases: number; + allocations: number; + backups: number; + }; + container: { + startup: string; + image: string; + environment: Record; + }; + createdAt: Date; + updatedAt: Date; + relationships: { + allocations?: Allocation[]; + nest?: Nest; + 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: { + include: [ '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..b306a41c8 --- /dev/null +++ b/resources/scripts/api/admin/transformers.ts @@ -0,0 +1,212 @@ +/* eslint-disable camelcase */ +import { Allocation, Node } from '@/api/admin/node'; +import { Server, ServerVariable } from '@/api/admin/server'; +import { FractalResponseData, FractalResponseList } from '@/api/http'; +import { User, UserRole } from '@/api/admin/user'; +import { Location } from '@/api/admin/location'; +import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; + +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, nest, 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, + nestId: attributes.nest_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, this.toAllocation), + nest: transform(nest as FractalResponseData | undefined, this.toNest), + egg: transform(egg as FractalResponseData | undefined, this.toEgg), + node: transform(node as FractalResponseData | undefined, this.toNode), + user: transform(user as FractalResponseData | undefined, this.toUser), + variables: transform(variables as FractalResponseList | undefined, this.toServerEggVariable), + }, + }; + }; + + static toNode = ({ attributes }: FractalResponseData): Node => { + return { + id: attributes.id, + uuid: attributes.uuid, + isPublic: attributes.public, + locationId: attributes.location_id, + databaseHostId: attributes.database_host_id, + name: attributes.name, + description: attributes.description, + fqdn: attributes.fqdn, + ports: { + http: { + public: attributes.publicPortHttp, + listen: attributes.listenPortHttp, + }, + sftp: { + public: attributes.publicPortSftp, + listen: attributes.listenPortSftp, + }, + }, + scheme: attributes.scheme, + isBehindProxy: attributes.behindProxy, + isMaintenanceMode: attributes.maintenance_mode, + memory: attributes.memory, + memoryOverallocate: attributes.memory_overallocate, + disk: attributes.disk, + diskOverallocate: attributes.disk_overallocate, + uploadSize: attributes.upload_size, + daemonBase: attributes.daemonBase, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + location: transform(attributes.relationships?.location as FractalResponseData, this.toLocation), + }, + }; + }; + + static toUserRole = ({ attributes }: FractalResponseData): UserRole => ({ + id: attributes.id, + name: attributes.name, + description: attributes.description, + relationships: {}, + }); + + static toUser = ({ attributes }: FractalResponseData): User => { + return { + id: attributes.id, + uuid: attributes.uuid, + externalId: attributes.external_id, + username: attributes.username, + email: attributes.email, + language: attributes.language, + adminRoleId: attributes.adminRoleId || null, + roleName: attributes.role_name, + isRootAdmin: attributes.root_admin, + isUsingTwoFactor: attributes['2fa'] || false, + avatarUrl: attributes.avatar_url, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + role: transform(attributes.relationships?.role as FractalResponseData, this.toUserRole) || null, + }, + }; + }; + + static toLocation = ({ attributes }: FractalResponseData): Location => ({ + id: attributes.id, + short: attributes.short, + long: attributes.long, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + nodes: transform(attributes.relationships?.node as FractalResponseList, this.toNode), + }, + }); + + static toEgg = ({ attributes }: FractalResponseData): Egg => ({ + id: attributes.id, + uuid: attributes.uuid, + nestId: attributes.nest_id, + author: attributes.author, + name: attributes.name, + description: attributes.description, + features: attributes.features, + dockerImages: attributes.docker_images, + configFiles: attributes.config?.files, + configStartup: attributes.config?.startup, + configStop: attributes.config?.stop, + configFrom: attributes.config?.extends, + startup: attributes.startup, + copyScriptFrom: attributes.copy_script_from, + scriptContainer: attributes.script?.container, + scriptEntry: attributes.script?.entry, + scriptIsPrivileged: attributes.script?.privileged, + scriptInstall: attributes.script?.install, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + nest: transform(attributes.relationships?.nest as FractalResponseData, this.toNest), + variables: transform(attributes.relationships?.variables as FractalResponseList, this.toEggVariable), + }, + }); + + static toEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({ + id: attributes.id, + eggId: attributes.egg_id, + name: attributes.name, + description: attributes.description, + environmentVariable: attributes.env_variable, + defaultValue: attributes.default_value, + isUserViewable: attributes.user_viewable, + isUserEditable: attributes.user_editable, + isRequired: attributes.required, + rules: attributes.rules, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: {}, + }); + + static toServerEggVariable = (data: FractalResponseData): ServerVariable => ({ + ...this.toEggVariable(data), + serverValue: data.attributes.server_value, + }); + + static toAllocation = ({ attributes }: FractalResponseData): Allocation => ({ + id: attributes.id, + ip: attributes.ip, + port: attributes.port, + alias: attributes.alias || null, + isAssigned: attributes.assigned, + relationships: { + node: transform(attributes.relationships?.node as FractalResponseData, this.toNode), + server: transform(attributes.relationships?.server as FractalResponseData, this.toServer), + }, + getDisplayText (): string { + const raw = `${this.ip}:${this.port}`; + + return !this.alias ? raw : `${this.alias} (${raw})`; + }, + }); + + static toNest = ({ attributes }: FractalResponseData): Nest => ({ + id: attributes.id, + uuid: attributes.uuid, + author: attributes.author, + name: attributes.name, + description: attributes.description, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + eggs: transform(attributes.relationships?.eggs as FractalResponseList, this.toEgg), + }, + }); +} diff --git a/resources/scripts/api/admin/user.ts b/resources/scripts/api/admin/user.ts new file mode 100644 index 000000000..b92315208 --- /dev/null +++ b/resources/scripts/api/admin/user.ts @@ -0,0 +1,44 @@ +import { Model, UUID } from '@/api/admin/index'; +import { Server } from '@/api/admin/server'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export interface User extends Model { + id: number; + uuid: UUID; + externalId: string; + username: string; + email: string; + language: string; + adminRoleId: number | null; + roleName: string; + isRootAdmin: boolean; + isUsingTwoFactor: boolean; + avatarUrl: string; + createdAt: Date; + updatedAt: Date; + relationships: { + role: UserRole | null; + servers?: Server[]; + }; +} + +export interface UserRole extends Model { + id: string; + name: string; + description: string; +} + +export const getUser = async (id: string | number): Promise => { + const { data } = await http.get(`/api/application/users/${id}`); + + return AdminTransformers.toUser(data.data); +}; + +export const searchUserAccounts = async (params: QueryBuilderParams<'username' | 'email'>): Promise => { + const { data } = await http.get('/api/application/users', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toUser); +}; diff --git a/resources/scripts/api/admin/users/searchUsers.ts b/resources/scripts/api/admin/users/searchUsers.ts deleted file mode 100644 index 450ca436c..000000000 --- a/resources/scripts/api/admin/users/searchUsers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import http from '@/api/http'; -import { User, rawDataToUser } from '@/api/admin/users/getUsers'; - -interface Filters { - username?: string; - email?: string; -} - -export default (filters?: Filters): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get('/api/application/users', { params: { ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToUser) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 4e33541ab..a97f63de6 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -111,3 +111,43 @@ export function getPaginationSet (data: any): PaginationDataSet { totalPages: data.total_pages, }; } + +type QueryBuilderFilterValue = string | number | boolean | null; + +export interface QueryBuilderParams { + filters?: { + [K in FilterKeys]?: QueryBuilderFilterValue | Readonly; + }; + sorts?: { + [K in SortKeys]?: -1 | 0 | 1 | 'asc' | 'desc' | null; + }; +} + +/** + * Helper function that parses a data object provided and builds query parameters + * for the Laravel Query Builder package automatically. This will apply sorts and + * filters deterministically based on the provided values. + */ +export const withQueryBuilderParams = (data?: QueryBuilderParams): Record => { + if (!data) return {}; + + const filters = Object.keys(data.filters || {}).reduce((obj, key) => { + const value = data.filters?.[key]; + + return !value || value === '' ? obj : { ...obj, [`filter[${key}]`]: value }; + }, {} as NonNullable); + + const sorts = Object.keys(data.sorts || {}).reduce((arr, key) => { + const value = data.sorts?.[key]; + if (!value || ![ 'asc', 'desc', 1, -1 ].includes(value)) { + return arr; + } + + return [ ...arr, (value === -1 || value === 'desc' ? '-' : '') + key ]; + }, [] as string[]); + + return { + ...filters, + sorts: !sorts.length ? undefined : sorts.join(','), + }; +}; 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/SubNavigation.tsx b/resources/scripts/components/admin/SubNavigation.tsx index e0dfe7091..42935c8b7 100644 --- a/resources/scripts/components/admin/SubNavigation.tsx +++ b/resources/scripts/components/admin/SubNavigation.tsx @@ -3,37 +3,38 @@ import { NavLink } from 'react-router-dom'; import tw, { styled } from 'twin.macro'; export const SubNavigation = styled.div` - ${tw`flex flex-row items-center flex-shrink-0 h-12 mb-4 border-b border-neutral-700`}; + ${tw`flex flex-row items-center flex-shrink-0 h-12 mb-4 border-b border-neutral-700`}; - & > div { - ${tw`flex flex-col justify-center flex-shrink-0 h-full`}; + & > a { + ${tw`flex flex-row items-center h-full px-4 border-b text-neutral-300 text-base whitespace-nowrap border-transparent`}; - & > a { - ${tw`flex flex-row items-center h-full px-4 border-t text-neutral-300`}; - border-top-color: transparent !important; - - & > svg { - ${tw`w-6 h-6 mr-2`}; - } - - & > span { - ${tw`text-base whitespace-nowrap`}; - } - - &:active, &.active { - ${tw`border-b text-primary-300 border-primary-300`}; - } - } + & > svg { + ${tw`w-6 h-6 mr-2`}; } + + &:active, &.active { + ${tw`text-primary-300 border-primary-300`}; + } + } `; -export const SubNavigationLink = ({ to, name, children }: { to: string, name: string, children: React.ReactNode }) => { - return ( -
- - {children} - {name} - -
- ); -}; +interface Props { + to: string; + name: string; +} + +interface PropsWithIcon extends Props { + icon: React.ComponentType; + children?: never; +} + +interface PropsWithoutIcon extends Props { + icon?: never; + children: React.ReactNode; +} + +export const SubNavigationLink = ({ to, name, icon: IconComponent, children }: PropsWithIcon | PropsWithoutIcon) => ( + + {IconComponent ? : children}{name} + +); diff --git a/resources/scripts/components/admin/servers/EggSelect.tsx b/resources/scripts/components/admin/servers/EggSelect.tsx index 115633ca0..2d708ba36 100644 --- a/resources/scripts/components/admin/servers/EggSelect.tsx +++ b/resources/scripts/components/admin/servers/EggSelect.tsx @@ -1,70 +1,60 @@ import Label from '@/components/elements/Label'; import Select from '@/components/elements/Select'; -import { useFormikContext } from 'formik'; +import { useField } from 'formik'; import React, { useEffect, useState } from 'react'; -import { Egg } from '@/api/admin/eggs/getEgg'; -import searchEggs from '@/api/admin/nests/searchEggs'; +import { Egg, searchEggs } from '@/api/admin/egg'; +import { WithRelationships } from '@/api/admin'; -export default ({ nestId, egg, setEgg }: { nestId: number | null; egg: Egg | null, setEgg: (value: Egg | null) => void }) => { - const { setFieldValue } = useFormikContext(); +interface Props { + nestId?: number; + selectedEggId?: number; + onEggSelect: (egg: Egg | null) => void; +} - const [ eggs, setEggs ] = useState([]); - - /** - * So you may be asking yourself, "what cluster-fuck of code is this?" - * - * Well, this code makes sure that when the egg changes, that the environment - * object has empty string values instead of undefined so React doesn't think - * the variable fields are uncontrolled. - */ - const setEgg2 = (newEgg: Egg | null) => { - if (newEgg === null) { - setEgg(null); - return; - } - - // Reset all variables to be empty, don't inherit the previous values. - const newVariables = newEgg?.relations.variables; - newVariables?.forEach(v => setFieldValue('environment.' + v.envVariable, '')); - const variables = egg?.relations.variables?.filter(v => newVariables?.find(v2 => v2.envVariable === v.envVariable) === undefined); - - setEgg(newEgg); - - // Clear any variables that don't exist on the new egg. - variables?.forEach(v => setFieldValue('environment.' + v.envVariable, undefined)); - }; +export default ({ nestId, selectedEggId, onEggSelect }: Props) => { + const [ , , { setValue, setTouched } ] = useField>('environment'); + const [ eggs, setEggs ] = useState[] | null>(null); useEffect(() => { - if (nestId === null) { - return; - } + if (!nestId) return setEggs(null); - searchEggs(nestId, {}, [ 'variables' ]) - .then(eggs => { - setEggs(eggs); - if (eggs.length < 1) { - setEgg2(null); - return; - } - setEgg2(eggs[0]); - }) - .catch(error => console.error(error)); + searchEggs(nestId, {}).then(eggs => { + setEggs(eggs); + onEggSelect(eggs[0] || null); + }).catch(error => console.error(error)); }, [ nestId ]); + const onSelectChange = (e: React.ChangeEvent) => { + if (!eggs) return; + + const match = eggs.find(egg => String(egg.id) === e.currentTarget.value); + if (!match) return onEggSelect(null); + + // Ensure that only new egg variables are present in the record storing all + // of the possible variables. This ensures the fields are controlled, rather + // than uncontrolled when a user begins typing in them. + setValue(match.relationships.variables.reduce((obj, value) => ({ + ...obj, + [value.environmentVariable]: undefined, + }), {})); + setTouched(true); + + onEggSelect(match); + }; + return ( <> - + {!eggs ? + + : + eggs.map(v => ( + + )) + } ); diff --git a/resources/scripts/components/admin/servers/NestSelect.tsx b/resources/scripts/components/admin/servers/NestSelect.tsx deleted file mode 100644 index 26e6d8b97..000000000 --- a/resources/scripts/components/admin/servers/NestSelect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Label from '@/components/elements/Label'; -import Select from '@/components/elements/Select'; -import React, { useEffect, useState } from 'react'; -import { Nest } from '@/api/admin/nests/getNests'; -import searchNests from '@/api/admin/nests/searchNests'; - -export default ({ nestId, setNestId }: { nestId: number | null; setNestId: (value: number | null) => void }) => { - const [ nests, setNests ] = useState(null); - - useEffect(() => { - searchNests({}) - .then(nests => setNests(nests)) - .catch(error => console.error(error)); - }, []); - - return ( - <> - - - - ); -}; diff --git a/resources/scripts/components/admin/servers/NestSelector.tsx b/resources/scripts/components/admin/servers/NestSelector.tsx new file mode 100644 index 000000000..5b56802c0 --- /dev/null +++ b/resources/scripts/components/admin/servers/NestSelector.tsx @@ -0,0 +1,36 @@ +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import React, { useEffect, useState } from 'react'; +import { Nest, searchNests } from '@/api/admin/nest'; + +interface Props { + selectedNestId?: number; + onNestSelect: (nest: number) => void; +} + +export default ({ selectedNestId, onNestSelect }: Props) => { + const [ nests, setNests ] = useState(null); + + useEffect(() => { + searchNests({}) + .then(setNests) + .catch(error => console.error(error)); + }, []); + + return ( + <> + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/OwnerSelect.tsx b/resources/scripts/components/admin/servers/OwnerSelect.tsx index c29348c69..38a4f8194 100644 --- a/resources/scripts/components/admin/servers/OwnerSelect.tsx +++ b/resources/scripts/components/admin/servers/OwnerSelect.tsx @@ -1,24 +1,18 @@ import React, { useState } from 'react'; import { useFormikContext } from 'formik'; -import { User } from '@/api/admin/users/getUsers'; -import searchUsers from '@/api/admin/users/searchUsers'; import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; +import { User, searchUserAccounts } from '@/api/admin/user'; -export default ({ selected }: { selected: User | null }) => { +export default ({ selected }: { selected: User }) => { const context = useFormikContext(); const [ user, setUser ] = useState(selected); const [ users, setUsers ] = useState(null); - const onSearch = (query: string): Promise => { - return new Promise((resolve, reject) => { - searchUsers({ username: query, email: query }) - .then((users) => { - setUsers(users); - return resolve(); - }) - .catch(reject); - }); + const onSearch = async (query: string) => { + setUsers( + await searchUserAccounts({ filters: { username: query, email: query } }) + ); }; const onSelect = (user: User | null) => { @@ -26,9 +20,7 @@ export default ({ selected }: { selected: User | null }) => { context.setFieldValue('ownerId', user?.id || null); }; - const getSelectedText = (user: User | null): string => { - return user?.email || ''; - }; + const getSelectedText = (user: User | null): string => user?.email || ''; return ( void; -} - -export default ({ serverId, onDeleted }: Props) => { +export default () => { + const history = useHistory(); const [ visible, setVisible ] = useState(false); const [ loading, setLoading ] = useState(false); + const { data: server } = useServerFromRoute(); - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); + const { + clearFlashes, + clearAndAddHttpError, + } = useStoreActions((actions: Actions) => actions.flashes); const onDelete = () => { + if (!server) return; + setLoading(true); clearFlashes('server'); - deleteServer(serverId) - .then(() => { - setLoading(false); - onDeleted(); - }) + deleteServer(server.id) + .then(() => history.push('/admin/servers')) .catch(error => { console.error(error); clearAndAddHttpError({ key: 'server', error }); @@ -35,6 +37,8 @@ export default ({ serverId, onDeleted }: Props) => { }); }; + if (!server) return null; + return ( <> { > Are you sure you want to delete this server? - - ); diff --git a/resources/scripts/components/admin/servers/ServerManageContainer.tsx b/resources/scripts/components/admin/servers/ServerManageContainer.tsx index 56c82b289..fba2f6d49 100644 --- a/resources/scripts/components/admin/servers/ServerManageContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerManageContainer.tsx @@ -1,17 +1,13 @@ import React from 'react'; import AdminBox from '@/components/admin/AdminBox'; import tw from 'twin.macro'; -import { Context } from '@/components/admin/servers/ServerRouter'; import Button from '@/components/elements/Button'; +import { useServerFromRoute } from '@/api/admin/server'; -const ServerManageContainer = () => { - const server = Context.useStoreState(state => state.server); +export default () => { + const { data: server } = useServerFromRoute(); - if (server === undefined) { - return ( - <> - ); - } + if (!server) return null; return (
@@ -52,17 +48,3 @@ const ServerManageContainer = () => {
); }; - -export default () => { - const server = Context.useStoreState(state => state.server); - - if (server === undefined) { - return ( - <> - ); - } - - return ( - - ); -}; diff --git a/resources/scripts/components/admin/servers/ServerRouter.tsx b/resources/scripts/components/admin/servers/ServerRouter.tsx index 0733f1536..9988b28c0 100644 --- a/resources/scripts/components/admin/servers/ServerRouter.tsx +++ b/resources/scripts/components/admin/servers/ServerRouter.tsx @@ -4,48 +4,29 @@ import React, { useEffect } from 'react'; import { useLocation } from 'react-router'; import tw from 'twin.macro'; import { Route, Switch, useRouteMatch } from 'react-router-dom'; -import { action, Action, createContextStore } from 'easy-peasy'; -import { Server } from '@/api/admin/servers/getServers'; import AdminContentBlock from '@/components/admin/AdminContentBlock'; 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'; +import { AdjustmentsIcon, CogIcon, DatabaseIcon, FolderIcon, ShieldExclamationIcon } from '@heroicons/react/outline'; -export const ServerIncludes = [ 'allocations', 'user', 'variables' ]; - -interface ctx { - server: Server | undefined; - setServer: Action; -} - -export const Context = createContextStore({ - server: undefined, - - setServer: action((state, payload) => { - state.server = payload; - }), -}); - -const ServerRouter = () => { +export default () => { const location = useLocation(); 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(); }, []); useEffect(() => { - if (!error) { - clearFlashes('server'); - } else { - clearAndAddHttpError({ error, key: 'server' }); - } + if (!error) clearFlashes('server'); + if (error) clearAndAddHttpError({ error, key: 'server' }); }, [ error ]); if (!server || (error && isValidating)) { @@ -67,60 +48,18 @@ const ServerRouter = () => { - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - + - + @@ -129,11 +68,3 @@ const ServerRouter = () => { ); }; - -export default () => { - return ( - - - - ); -}; diff --git a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx index b3a25ee40..ff9e93423 100644 --- a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx @@ -1,168 +1,22 @@ -import getAllocations from '@/api/admin/nodes/getAllocations'; -import { Server } from '@/api/admin/servers/getServers'; +import { useServerFromRoute } from '@/api/admin/server'; 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 React from 'react'; -import AdminBox from '@/components/admin/AdminBox'; -import { useHistory } from 'react-router-dom'; import tw from 'twin.macro'; import { object } from 'yup'; import updateServer, { Values } from '@/api/admin/servers/updateServer'; -import Field from '@/components/elements/Field'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; -import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; -import { Context, ServerIncludes } from '@/components/admin/servers/ServerRouter'; -import { ApplicationStore } from '@/state'; -import { Actions, useStoreActions } from 'easy-peasy'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { useStoreActions } from 'easy-peasy'; 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'; +import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox'; +import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox'; -export function ServerAllocationsContainer ({ server }: { server: Server }) { - const { isSubmitting } = useFormikContext(); +export default () => { + const { data: server, mutate } = useServerFromRoute(); + const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes); - 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`} - /> -
- ); -} - -export function ServerResourceContainer () { - const { isSubmitting } = useFormikContext(); - - return ( - - - -
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
- -
-
- -
-
-
- ); -} - -export default function ServerSettingsContainer2 ({ server }: { server: Server }) { - const history = useHistory(); - - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - - const setServer = Context.useStoreActions(actions => actions.setServer); + if (!server) return null; const submit = (values: Values, { setSubmitting, setFieldValue }: FormikHelpers) => { clearFlashes('server'); @@ -171,9 +25,9 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } // OOM Killer is enabled, rather than when disabled. values.limits.oomDisabled = !values.limits.oomDisabled; - updateServer(server.id, values, ServerIncludes) + updateServer(server.id, values) .then(s => { - setServer({ ...server, ...s }); + // setServer({ ...server, ...s }); // TODO: Figure out how to properly clear react-selects for allocations. setFieldValue('addAllocations', []); @@ -192,8 +46,7 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } initialValues={{ externalId: server.externalId || '', name: server.name, - ownerId: server.ownerId, - + ownerId: server.userId, limits: { memory: server.limits.memory, swap: server.limits.swap, @@ -205,19 +58,16 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } // OOM Killer is enabled, rather than when disabled. oomDisabled: !server.limits.oomDisabled, }, - featureLimits: { allocations: server.featureLimits.allocations, backups: server.featureLimits.backups, databases: server.featureLimits.databases, }, - allocationId: server.allocationId, addAllocations: [] as number[], removeAllocations: [] as number[], }} - validationSchema={object().shape({ - })} + validationSchema={object().shape({})} > {({ isSubmitting, isValid }) => (
@@ -225,19 +75,19 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server }
- +
-
- - -
+ +
- history.push('/admin/servers')} - /> -
@@ -248,4 +98,4 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } )} ); -} +}; diff --git a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx index 2184b217a..193f51106 100644 --- a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx @@ -1,9 +1,7 @@ -import { getEgg, Egg, EggVariable } from '@/api/admin/eggs/getEgg'; -import { Server } from '@/api/admin/servers/getServers'; +import { Egg, EggVariable, getEgg } from '@/api/admin/egg'; import updateServerStartup, { Values } from '@/api/admin/servers/updateServerStartup'; import EggSelect from '@/components/admin/servers/EggSelect'; -import NestSelect from '@/components/admin/servers/NestSelect'; -import { Context, ServerIncludes } from '@/components/admin/servers/ServerRouter'; +import NestSelector from '@/components/admin/servers/NestSelector'; import FormikSwitch from '@/components/elements/FormikSwitch'; import React, { useEffect, useState } from 'react'; import Button from '@/components/elements/Button'; @@ -17,6 +15,8 @@ import { ApplicationStore } from '@/state'; import { Actions, useStoreActions } from 'easy-peasy'; import Label from '@/components/elements/Label'; import { object } from 'yup'; +import { Server, useServerFromRoute } from '@/api/admin/server'; +import { InferModel } from '@/api/admin'; function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server: Server }) { const { isSubmitting, setFieldValue } = useFormikContext(); @@ -60,26 +60,18 @@ function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server: function ServerServiceContainer ({ egg, setEgg, server }: { egg: Egg | null, setEgg: (value: Egg | null) => void, server: Server }) { const { isSubmitting } = useFormikContext(); - const [ nestId, setNestId ] = useState(server.nestId); + const [ nestId, setNestId ] = useState(server.nestId); return ( - - - +
- +
-
- +
-
- +
); @@ -107,7 +99,7 @@ function ServerImageContainer () { } function ServerVariableContainer ({ variable, defaultValue }: { variable: EggVariable, defaultValue: string }) { - const key = 'environment.' + variable.envVariable; + const key = 'environment.' + variable.environmentVariable; const { isSubmitting, setFieldValue } = useFormikContext(); @@ -158,11 +150,11 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg:
- {egg?.relations.variables?.map((v, i) => ( + {egg?.relationships.variables?.map((v, i) => ( v.eggId === v2.eggId && v.envVariable === v2.envVariable)?.serverValue || v.defaultValue} + defaultValue={server.relationships?.variables?.find(v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable)?.serverValue || v.defaultValue} /> ))}
@@ -179,26 +171,28 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg: ); } -export default function ServerStartupContainer ({ server }: { server: Server }) { +export default () => { + const { data: server } = useServerFromRoute(); const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - - const [ egg, setEgg ] = useState(null); - - const setServer = Context.useStoreActions(actions => actions.setServer); + const [ egg, setEgg ] = useState | null>(null); useEffect(() => { + if (!server) return; + getEgg(server.eggId) .then(egg => setEgg(egg)) .catch(error => console.error(error)); - }, []); + }, [ server?.eggId ]); + + if (!server) return null; const submit = (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes('server'); - updateServerStartup(server.id, values, ServerIncludes) - .then(s => { - setServer({ ...server, ...s }); - }) + updateServerStartup(server.id, values) + // .then(s => { + // mutate(data => { ...data, ...s }); + // }) .catch(error => { console.error(error); clearAndAddHttpError({ key: 'server', error }); @@ -212,7 +206,7 @@ export default function ServerStartupContainer ({ server }: { server: Server }) initialValues={{ startup: server.container.startup, // Don't ask. - environment: Object.fromEntries(egg?.relations.variables?.map(v => [ v.envVariable, '' ]) || []), + environment: Object.fromEntries(egg?.relationships.variables.map(v => [ v.environmentVariable, '' ]) || []), image: server.container.image, eggId: server.eggId, skipScripts: false, @@ -222,9 +216,10 @@ export default function ServerStartupContainer ({ server }: { server: Server }) > ); -} +}; 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 + /> +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx b/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx new file mode 100644 index 000000000..b121eeeb8 --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx @@ -0,0 +1,70 @@ +import { useFormikContext } from 'formik'; +import AdminBox from '@/components/admin/AdminBox'; +import { faBalanceScale } from '@fortawesome/free-solid-svg-icons'; +import tw from 'twin.macro'; +import Field from '@/components/elements/Field'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import React from 'react'; +import { useServerFromRoute } from '@/api/admin/server'; + +export default () => { + const { isSubmitting } = useFormikContext(); + const { data: server } = useServerFromRoute(); + + if (!server) return null; + + return ( + +
+ + + + + + +
+ +
+
+
+ ); +}; diff --git a/resources/scripts/components/elements/Button.tsx b/resources/scripts/components/elements/Button.tsx index 2f2ed2500..e4804c8a8 100644 --- a/resources/scripts/components/elements/Button.tsx +++ b/resources/scripts/components/elements/Button.tsx @@ -9,13 +9,9 @@ interface Props { isSecondary?: boolean; } -const ButtonStyle = styled.button>` +const ButtonStyle = styled.button` ${tw`relative inline-block rounded p-2 tracking-wide text-sm transition-all duration-150 border`}; - - & > span { - ${tw`select-none`}; - } - + ${props => ((!props.isSecondary && !props.color) || props.color === 'primary') && css` ${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`}; @@ -76,22 +72,24 @@ const ButtonStyle = styled.button>` ${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`}; } `}; + + ${props => props.isLoading && tw`text-transparent`}; - &:disabled { opacity: 0.55; cursor: default } + &:disabled { + ${tw`opacity-75 cursor-not-allowed`}; + } `; type ComponentProps = Omit & Props; -const Button: React.FC = ({ children, isLoading, ...props }) => ( - +const Button: React.FC = ({ children, isLoading, disabled, ...props }) => ( + {isLoading &&
} - - {children} - + {children}
);