mirror of
https://github.com/pterodactyl/panel.git
synced 2024-11-21 16:42:29 +01:00
ui(admin): add "working" React admin ui
This commit is contained in:
parent
d1c7494933
commit
5402584508
@ -33,6 +33,7 @@ use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
|
|||||||
use Pterodactyl\Http\Middleware\Api\Client\SubstituteClientBindings;
|
use Pterodactyl\Http\Middleware\Api\Client\SubstituteClientBindings;
|
||||||
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
|
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||||
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser;
|
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser;
|
||||||
|
use Pterodactyl\Http\Middleware\Api\Application\SubstituteApplicationApiBindings;
|
||||||
|
|
||||||
class Kernel extends HttpKernel
|
class Kernel extends HttpKernel
|
||||||
{
|
{
|
||||||
@ -70,6 +71,7 @@ class Kernel extends HttpKernel
|
|||||||
AuthenticateIPAccess::class,
|
AuthenticateIPAccess::class,
|
||||||
],
|
],
|
||||||
'application-api' => [
|
'application-api' => [
|
||||||
|
// SubstituteApplicationApiBindings::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
AuthenticateApplicationUser::class,
|
AuthenticateApplicationUser::class,
|
||||||
],
|
],
|
||||||
|
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Middleware\Api\Application;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Pterodactyl\Models\Egg;
|
||||||
|
use Pterodactyl\Models\Nest;
|
||||||
|
use Pterodactyl\Models\Node;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Models\Database;
|
||||||
|
use Pterodactyl\Models\Location;
|
||||||
|
use Pterodactyl\Models\Allocation;
|
||||||
|
use Illuminate\Contracts\Routing\Registrar;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
|
||||||
|
class SubstituteApplicationApiBindings
|
||||||
|
{
|
||||||
|
protected Registrar $router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappings to automatically assign route parameters to a model.
|
||||||
|
*/
|
||||||
|
protected static array $mappings = [
|
||||||
|
'allocation' => Allocation::class,
|
||||||
|
'database' => Database::class,
|
||||||
|
'egg' => Egg::class,
|
||||||
|
'location' => Location::class,
|
||||||
|
'nest' => Nest::class,
|
||||||
|
'node' => Node::class,
|
||||||
|
'server' => Server::class,
|
||||||
|
'user' => User::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(Registrar $router)
|
||||||
|
{
|
||||||
|
$this->router = $router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform substitution of route parameters without triggering
|
||||||
|
* a 404 error if a model is not found.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function handle($request, Closure $next)
|
||||||
|
{
|
||||||
|
foreach (self::$mappings as $key => $class) {
|
||||||
|
$this->router->bind($key, $class);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->router->substituteImplicitBindings($route = $request->route());
|
||||||
|
} catch (ModelNotFoundException $exception) {
|
||||||
|
if (!empty($route) && $route->getMissing()) {
|
||||||
|
$route->getMissing()($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
@ -39,6 +39,7 @@
|
|||||||
"@codemirror/view": "^6.0.0",
|
"@codemirror/view": "^6.0.0",
|
||||||
"@floating-ui/react-dom-interactions": "0.13.3",
|
"@floating-ui/react-dom-interactions": "0.13.3",
|
||||||
"@fortawesome/fontawesome-svg-core": "6.2.1",
|
"@fortawesome/fontawesome-svg-core": "6.2.1",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "6.2.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
||||||
"@fortawesome/react-fontawesome": "0.2.0",
|
"@fortawesome/react-fontawesome": "0.2.0",
|
||||||
"@flyyer/use-fit-text": "3.0.1",
|
"@flyyer/use-fit-text": "3.0.1",
|
||||||
@ -72,6 +73,7 @@
|
|||||||
"react-fast-compare": "3.2.0",
|
"react-fast-compare": "3.2.0",
|
||||||
"react-i18next": "12.1.1",
|
"react-i18next": "12.1.1",
|
||||||
"react-router-dom": "6.4.5",
|
"react-router-dom": "6.4.5",
|
||||||
|
"react-select": "5.7.0",
|
||||||
"reaptcha": "1.12.1",
|
"reaptcha": "1.12.1",
|
||||||
"sockette": "2.0.6",
|
"sockette": "2.0.6",
|
||||||
"styled-components": "5.3.6",
|
"styled-components": "5.3.6",
|
||||||
@ -109,7 +111,7 @@
|
|||||||
"eslint-plugin-react": "7.31.11",
|
"eslint-plugin-react": "7.31.11",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"happy-dom": "8.1.0",
|
"happy-dom": "8.1.0",
|
||||||
"laravel-vite-plugin": "0.7.1",
|
"laravel-vite-plugin": "0.7.2",
|
||||||
"pathe": "1.0.0",
|
"pathe": "1.0.0",
|
||||||
"postcss": "8.4.20",
|
"postcss": "8.4.20",
|
||||||
"postcss-nesting": "10.2.0",
|
"postcss-nesting": "10.2.0",
|
||||||
|
12
resources/scripts/api/admin/databases/createDatabase.ts
Normal file
12
resources/scripts/api/admin/databases/createDatabase.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||||
|
|
||||||
|
export default (name: string, host: string, port: number, username: string, password: string, include: string[] = []): Promise<Database> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/databases', {
|
||||||
|
name, host, port, username, password,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToDatabase(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/databases/deleteDatabase.ts
Normal file
9
resources/scripts/api/admin/databases/deleteDatabase.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/databases/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
10
resources/scripts/api/admin/databases/getDatabase.ts
Normal file
10
resources/scripts/api/admin/databases/getDatabase.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||||
|
|
||||||
|
export default (id: number, include: string[] = []): Promise<Database> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/databases/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToDatabase(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
64
resources/scripts/api/admin/databases/getDatabases.ts
Normal file
64
resources/scripts/api/admin/databases/getDatabases.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
maxDatabases: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
getAddress (): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToDatabase = ({ attributes }: FractalResponseData): Database => ({
|
||||||
|
id: attributes.id,
|
||||||
|
name: attributes.name,
|
||||||
|
host: attributes.host,
|
||||||
|
port: attributes.port,
|
||||||
|
username: attributes.username,
|
||||||
|
maxDatabases: attributes.max_databases,
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
|
||||||
|
getAddress: () => `${attributes.host}:${attributes.port}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Database>>([ 'databases', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/databases', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToDatabase),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
25
resources/scripts/api/admin/databases/searchDatabases.ts
Normal file
25
resources/scripts/api/admin/databases/searchDatabases.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (filters?: Filters): Promise<Database[]> => {
|
||||||
|
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/databases', { params })
|
||||||
|
.then(response => resolve(
|
||||||
|
(response.data.data || []).map(rawDataToDatabase)
|
||||||
|
))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
12
resources/scripts/api/admin/databases/updateDatabase.ts
Normal file
12
resources/scripts/api/admin/databases/updateDatabase.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||||
|
|
||||||
|
export default (id: number, name: string, host: string, port: number, username: string, password: string | undefined, include: string[] = []): Promise<Database> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/databases/${id}`, {
|
||||||
|
name, host, port, username, password,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToDatabase(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
104
resources/scripts/api/admin/egg.ts
Normal file
104
resources/scripts/api/admin/egg.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import type { SWRResponse } from 'swr';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import type { Model, UUID, WithRelationships } from '@/api/admin/index';
|
||||||
|
import { withRelationships } from '@/api/admin/index';
|
||||||
|
import type { Nest } from '@/api/admin/nest';
|
||||||
|
import type { QueryBuilderParams } from '@/api/http';
|
||||||
|
import http, { withQueryBuilderParams } from '@/api/http';
|
||||||
|
import { Transformers } from '@definitions/admin';
|
||||||
|
|
||||||
|
export interface Egg extends Model {
|
||||||
|
id: number;
|
||||||
|
uuid: UUID;
|
||||||
|
nestId: number;
|
||||||
|
author: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
features: string[] | null;
|
||||||
|
dockerImages: Record<string, string>;
|
||||||
|
configFiles: Record<string, any> | null;
|
||||||
|
configStartup: Record<string, any> | 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standard API response with the minimum viable details for the frontend
|
||||||
|
* to correctly render a egg.
|
||||||
|
*/
|
||||||
|
type LoadedEgg = WithRelationships<Egg, 'nest' | 'variables'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a single egg from the database and returns it.
|
||||||
|
*/
|
||||||
|
export const getEgg = async (id: number | string): Promise<LoadedEgg> => {
|
||||||
|
const { data } = await http.get(`/api/application/eggs/${id}`, {
|
||||||
|
params: {
|
||||||
|
include: ['nest', 'variables'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return withRelationships(Transformers.toEgg(data), 'nest', 'variables');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchEggs = async (
|
||||||
|
nestId: number,
|
||||||
|
params: QueryBuilderParams<'name'>,
|
||||||
|
): Promise<WithRelationships<Egg, 'variables'>[]> => {
|
||||||
|
const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, {
|
||||||
|
params: {
|
||||||
|
...withQueryBuilderParams(params),
|
||||||
|
include: ['variables'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.data.map(Transformers.toEgg);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportEgg = async (eggId: number): Promise<Record<string, any>> => {
|
||||||
|
const { data } = await http.get(`/api/application/eggs/${eggId}/export`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an SWR instance by automatically loading in the server for the currently
|
||||||
|
* loaded route match in the admin area.
|
||||||
|
*/
|
||||||
|
export const useEggFromRoute = (): SWRResponse<LoadedEgg, AxiosError> => {
|
||||||
|
const params = useParams<'id'>();
|
||||||
|
|
||||||
|
return useSWR(`/api/application/eggs/${params.id}`, async () => getEgg(Number(params.id)), {
|
||||||
|
revalidateOnMount: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
};
|
31
resources/scripts/api/admin/eggs/createEgg.ts
Normal file
31
resources/scripts/api/admin/eggs/createEgg.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
|
||||||
|
type Egg2 = Omit<Omit<Partial<Egg>, 'configFiles'>, 'configStartup'> & { configFiles: string, configStartup: string };
|
||||||
|
|
||||||
|
export default (egg: Partial<Egg2>): Promise<Egg> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post(
|
||||||
|
'/api/application/eggs',
|
||||||
|
{
|
||||||
|
nest_id: egg.nestId,
|
||||||
|
name: egg.name,
|
||||||
|
description: egg.description,
|
||||||
|
features: egg.features,
|
||||||
|
docker_images: egg.dockerImages,
|
||||||
|
config_files: egg.configFiles,
|
||||||
|
config_startup: egg.configStartup,
|
||||||
|
config_stop: egg.configStop,
|
||||||
|
config_from: egg.configFrom,
|
||||||
|
startup: egg.startup,
|
||||||
|
script_container: egg.scriptContainer,
|
||||||
|
copy_script_from: egg.copyScriptFrom,
|
||||||
|
script_entry: egg.scriptEntry,
|
||||||
|
script_is_privileged: egg.scriptIsPrivileged,
|
||||||
|
script_install: egg.scriptInstall,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(({ data }) => resolve(rawDataToEgg(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
22
resources/scripts/api/admin/eggs/createEggVariable.ts
Normal file
22
resources/scripts/api/admin/eggs/createEggVariable.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { EggVariable } from '@/api/admin/egg';
|
||||||
|
import { Transformers } from '@definitions/admin';
|
||||||
|
|
||||||
|
export type CreateEggVariable = Omit<EggVariable, 'id' | 'eggId' | 'createdAt' | 'updatedAt' | 'relationships'>;
|
||||||
|
|
||||||
|
export default async (eggId: number, variable: CreateEggVariable): Promise<EggVariable> => {
|
||||||
|
const { data } = await http.post(
|
||||||
|
`/api/application/eggs/${eggId}/variables`,
|
||||||
|
{
|
||||||
|
name: variable.name,
|
||||||
|
description: variable.description,
|
||||||
|
env_variable: variable.environmentVariable,
|
||||||
|
default_value: variable.defaultValue,
|
||||||
|
user_viewable: variable.isUserViewable,
|
||||||
|
user_editable: variable.isUserEditable,
|
||||||
|
rules: variable.rules,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Transformers.toEggVariable(data);
|
||||||
|
};
|
9
resources/scripts/api/admin/eggs/deleteEgg.ts
Normal file
9
resources/scripts/api/admin/eggs/deleteEgg.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/eggs/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/eggs/deleteEggVariable.ts
Normal file
9
resources/scripts/api/admin/eggs/deleteEggVariable.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (eggId: number, variableId: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/eggs/${eggId}/variables/${variableId}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
108
resources/scripts/api/admin/eggs/getEgg.ts
Normal file
108
resources/scripts/api/admin/eggs/getEgg.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Nest } from '@/api/admin/nests/getNests';
|
||||||
|
import { rawDataToServer, Server } from '@/api/admin/servers/getServers';
|
||||||
|
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
export interface EggVariable {
|
||||||
|
id: number;
|
||||||
|
eggId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
envVariable: string;
|
||||||
|
defaultValue: string;
|
||||||
|
userViewable: boolean;
|
||||||
|
userEditable: boolean;
|
||||||
|
rules: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({
|
||||||
|
id: attributes.id,
|
||||||
|
eggId: attributes.egg_id,
|
||||||
|
name: attributes.name,
|
||||||
|
description: attributes.description,
|
||||||
|
envVariable: attributes.env_variable,
|
||||||
|
defaultValue: attributes.default_value,
|
||||||
|
userViewable: attributes.user_viewable,
|
||||||
|
userEditable: attributes.user_editable,
|
||||||
|
rules: attributes.rules,
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Egg {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
nestId: number;
|
||||||
|
author: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
features: string[] | null;
|
||||||
|
dockerImages: Record<string, string>;
|
||||||
|
configFiles: Record<string, any> | null;
|
||||||
|
configStartup: Record<string, any> | 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;
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
nest?: Nest;
|
||||||
|
servers?: Server[];
|
||||||
|
variables?: EggVariable[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToEgg = ({ 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),
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
nest: undefined,
|
||||||
|
servers: ((attributes.relationships?.servers as FractalResponseList | undefined)?.data || []).map(
|
||||||
|
rawDataToServer,
|
||||||
|
),
|
||||||
|
variables: ((attributes.relationships?.variables as FractalResponseList | undefined)?.data || []).map(
|
||||||
|
rawDataToEggVariable,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getEgg = async (id: number): Promise<Egg> => {
|
||||||
|
const { data } = await http.get(`/api/application/eggs/${id}`, { params: { include: ['variables'] } });
|
||||||
|
|
||||||
|
return rawDataToEgg(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (id: number) => {
|
||||||
|
return useSWR<Egg>(`egg:${id}`, async () => {
|
||||||
|
const { data } = await http.get(`/api/application/eggs/${id}`, { params: { include: ['variables'] } });
|
||||||
|
|
||||||
|
return rawDataToEgg(data);
|
||||||
|
});
|
||||||
|
};
|
31
resources/scripts/api/admin/eggs/updateEgg.ts
Normal file
31
resources/scripts/api/admin/eggs/updateEgg.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
|
||||||
|
type Egg2 = Omit<Omit<Partial<Egg>, 'configFiles'>, 'configStartup'> & { configFiles?: string, configStartup?: string };
|
||||||
|
|
||||||
|
export default (id: number, egg: Partial<Egg2>): Promise<Egg> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(
|
||||||
|
`/api/application/eggs/${id}`,
|
||||||
|
{
|
||||||
|
nest_id: egg.nestId,
|
||||||
|
name: egg.name,
|
||||||
|
description: egg.description,
|
||||||
|
features: egg.features,
|
||||||
|
docker_images: egg.dockerImages,
|
||||||
|
config_files: egg.configFiles,
|
||||||
|
config_startup: egg.configStartup,
|
||||||
|
config_stop: egg.configStop,
|
||||||
|
config_from: egg.configFrom,
|
||||||
|
startup: egg.startup,
|
||||||
|
script_container: egg.scriptContainer,
|
||||||
|
copy_script_from: egg.copyScriptFrom,
|
||||||
|
script_entry: egg.scriptEntry,
|
||||||
|
script_is_privileged: egg.scriptIsPrivileged,
|
||||||
|
script_install: egg.scriptInstall,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(({ data }) => resolve(rawDataToEgg(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
21
resources/scripts/api/admin/eggs/updateEggVariables.ts
Normal file
21
resources/scripts/api/admin/eggs/updateEggVariables.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { EggVariable } from '@/api/admin/egg';
|
||||||
|
import { Transformers } from '@definitions/admin';
|
||||||
|
|
||||||
|
export default async (eggId: number, variables: Omit<EggVariable, 'eggId' | 'createdAt' | 'updatedAt'>[]): Promise<EggVariable[]> => {
|
||||||
|
const { data } = await http.patch(
|
||||||
|
`/api/application/eggs/${eggId}/variables`,
|
||||||
|
variables.map(variable => ({
|
||||||
|
id: variable.id,
|
||||||
|
name: variable.name,
|
||||||
|
description: variable.description,
|
||||||
|
env_variable: variable.environmentVariable,
|
||||||
|
default_value: variable.defaultValue,
|
||||||
|
user_viewable: variable.isUserViewable,
|
||||||
|
user_editable: variable.isUserEditable,
|
||||||
|
rules: variable.rules,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.data.map(Transformers.toEggVariable);
|
||||||
|
};
|
22
resources/scripts/api/admin/getVersion.ts
Normal file
22
resources/scripts/api/admin/getVersion.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export interface VersionData {
|
||||||
|
panel: {
|
||||||
|
current: string;
|
||||||
|
latest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
wings: {
|
||||||
|
latest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
git: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (): Promise<VersionData> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get('/api/application/version')
|
||||||
|
.then(({ data }) => resolve(data))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
66
resources/scripts/api/admin/index.ts
Normal file
66
resources/scripts/api/admin/index.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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 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<typeof getEgg>;
|
||||||
|
*/
|
||||||
|
export type InferModel<T extends (...args: any) => any> = ReturnType<T> extends Promise<infer U> ? 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 = <M extends Model, R extends string> (model: M, ..._keys: R[]) => {
|
||||||
|
return model as unknown as WithRelationships<M, R>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ListContext<T> {
|
||||||
|
page: number;
|
||||||
|
setPage: (page: ((p: number) => number) | number) => void;
|
||||||
|
|
||||||
|
filters: T | null;
|
||||||
|
setFilters: (filters: ((f: T | null) => T | null) | T | null) => void;
|
||||||
|
|
||||||
|
sort: string | null;
|
||||||
|
setSort: (sort: string | null) => void;
|
||||||
|
|
||||||
|
sortDirection: boolean;
|
||||||
|
setSortDirection: (direction: ((p: boolean) => boolean) | boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function create<T> () {
|
||||||
|
return createContext<ListContext<T>>({
|
||||||
|
page: 1,
|
||||||
|
setPage: () => 1,
|
||||||
|
|
||||||
|
filters: null,
|
||||||
|
setFilters: () => null,
|
||||||
|
|
||||||
|
sort: null,
|
||||||
|
setSort: () => null,
|
||||||
|
|
||||||
|
sortDirection: false,
|
||||||
|
setSortDirection: () => false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { create as createContext };
|
13
resources/scripts/api/admin/location.ts
Normal file
13
resources/scripts/api/admin/location.ts
Normal file
@ -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[];
|
||||||
|
};
|
||||||
|
}
|
12
resources/scripts/api/admin/locations/createLocation.ts
Normal file
12
resources/scripts/api/admin/locations/createLocation.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations';
|
||||||
|
|
||||||
|
export default (short: string, long: string | null, include: string[] = []): Promise<Location> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/locations', {
|
||||||
|
short, long,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToLocation(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/locations/deleteLocation.ts
Normal file
9
resources/scripts/api/admin/locations/deleteLocation.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/locations/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
10
resources/scripts/api/admin/locations/getLocation.ts
Normal file
10
resources/scripts/api/admin/locations/getLocation.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations';
|
||||||
|
|
||||||
|
export default (id: number, include: string[] = []): Promise<Location> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/locations/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToLocation(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
54
resources/scripts/api/admin/locations/getLocations.ts
Normal file
54
resources/scripts/api/admin/locations/getLocations.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: number;
|
||||||
|
short: string;
|
||||||
|
long: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToLocation = ({ attributes }: FractalResponseData): Location => ({
|
||||||
|
id: attributes.id,
|
||||||
|
short: attributes.short,
|
||||||
|
long: attributes.long,
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
short?: string;
|
||||||
|
long?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Location>>([ 'locations', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/locations', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToLocation),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
25
resources/scripts/api/admin/locations/searchLocations.ts
Normal file
25
resources/scripts/api/admin/locations/searchLocations.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations';
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
short?: string;
|
||||||
|
long?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (filters?: Filters): Promise<Location[]> => {
|
||||||
|
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/locations', { params })
|
||||||
|
.then(response => resolve(
|
||||||
|
(response.data.data || []).map(rawDataToLocation)
|
||||||
|
))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
12
resources/scripts/api/admin/locations/updateLocation.ts
Normal file
12
resources/scripts/api/admin/locations/updateLocation.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations';
|
||||||
|
|
||||||
|
export default (id: number, short: string, long: string | null, include: string[] = []): Promise<Location> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/locations/${id}`, {
|
||||||
|
short, long,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToLocation(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
12
resources/scripts/api/admin/mounts/createMount.ts
Normal file
12
resources/scripts/api/admin/mounts/createMount.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Mount, rawDataToMount } from '@/api/admin/mounts/getMounts';
|
||||||
|
|
||||||
|
export default (name: string, description: string, source: string, target: string, readOnly: boolean, userMountable: boolean, include: string[] = []): Promise<Mount> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/mounts', {
|
||||||
|
name, description, source, target, read_only: readOnly, user_mountable: userMountable,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToMount(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/mounts/deleteMount.ts
Normal file
9
resources/scripts/api/admin/mounts/deleteMount.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/mounts/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
10
resources/scripts/api/admin/mounts/getMount.ts
Normal file
10
resources/scripts/api/admin/mounts/getMount.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Mount, rawDataToMount } from '@/api/admin/mounts/getMounts';
|
||||||
|
|
||||||
|
export default (id: number, include: string[] = []): Promise<Mount> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/mounts/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToMount(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
80
resources/scripts/api/admin/mounts/getMounts.ts
Normal file
80
resources/scripts/api/admin/mounts/getMounts.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes';
|
||||||
|
import { Server, rawDataToServer } from '@/api/admin/servers/getServers';
|
||||||
|
|
||||||
|
export interface Mount {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
userMountable: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
eggs: Egg[] | undefined;
|
||||||
|
nodes: Node[] | undefined;
|
||||||
|
servers: Server[] | undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToMount = ({ attributes }: FractalResponseData): Mount => ({
|
||||||
|
id: attributes.id,
|
||||||
|
uuid: attributes.uuid,
|
||||||
|
name: attributes.name,
|
||||||
|
description: attributes.description,
|
||||||
|
source: attributes.source,
|
||||||
|
target: attributes.target,
|
||||||
|
readOnly: attributes.read_only,
|
||||||
|
userMountable: attributes.user_mountable,
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
eggs: ((attributes.relationships?.eggs as FractalResponseList | undefined)?.data || []).map(rawDataToEgg),
|
||||||
|
nodes: ((attributes.relationships?.nodes as FractalResponseList | undefined)?.data || []).map(rawDataToNode),
|
||||||
|
servers: ((attributes.relationships?.servers as FractalResponseList | undefined)?.data || []).map(rawDataToServer),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
source?: string;
|
||||||
|
target?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Mount>>([ 'mounts', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/mounts', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToMount),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
12
resources/scripts/api/admin/mounts/updateMount.ts
Normal file
12
resources/scripts/api/admin/mounts/updateMount.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Mount, rawDataToMount } from '@/api/admin/mounts/getMounts';
|
||||||
|
|
||||||
|
export default (id: number, name: string, description: string | null, source: string, target: string, readOnly: boolean, userMountable: boolean, include: string[] = []): Promise<Mount> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/mounts/${id}`, {
|
||||||
|
name, description, source, target, read_only: readOnly, user_mountable: userMountable,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToMount(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
25
resources/scripts/api/admin/nest.ts
Normal file
25
resources/scripts/api/admin/nest.ts
Normal file
@ -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 { Transformers } from '@definitions/admin';
|
||||||
|
|
||||||
|
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<Nest[]> => {
|
||||||
|
const { data } = await http.get('/api/application/nests', {
|
||||||
|
params: withQueryBuilderParams(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.data.map(Transformers.toNest);
|
||||||
|
};
|
12
resources/scripts/api/admin/nests/createNest.ts
Normal file
12
resources/scripts/api/admin/nests/createNest.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Nest, rawDataToNest } from '@/api/admin/nests/getNests';
|
||||||
|
|
||||||
|
export default (name: string, description: string | null, include: string[] = []): Promise<Nest> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/nests', {
|
||||||
|
name, description,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToNest(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/nests/deleteNest.ts
Normal file
9
resources/scripts/api/admin/nests/deleteNest.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/nests/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
38
resources/scripts/api/admin/nests/getEggs.ts
Normal file
38
resources/scripts/api/admin/nests/getEggs.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (nestId: number, include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Egg>>([ nestId, 'eggs', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToEgg),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
10
resources/scripts/api/admin/nests/getNest.ts
Normal file
10
resources/scripts/api/admin/nests/getNest.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Nest, rawDataToNest } from '@/api/admin/nests/getNests';
|
||||||
|
|
||||||
|
export default (id: number, include: string[]): Promise<Nest> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/nests/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToNest(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
66
resources/scripts/api/admin/nests/getNests.ts
Normal file
66
resources/scripts/api/admin/nests/getNests.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
|
||||||
|
export interface Nest {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
author: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
eggs: Egg[] | undefined;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToNest = ({ 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),
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
eggs: ((attributes.relationships?.eggs as FractalResponseList | undefined)?.data || []).map(rawDataToEgg),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Nest>>([ 'nests', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/nests', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToNest),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
17
resources/scripts/api/admin/nests/importEgg.ts
Normal file
17
resources/scripts/api/admin/nests/importEgg.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
|
||||||
|
export default (id: number, content: any, type = 'application/json', include: string[] = []): Promise<Egg> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post(`/api/application/nests/${id}/import`, content, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': type,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
include: include.join(','),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(({ data }) => resolve(rawDataToEgg(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
12
resources/scripts/api/admin/nests/updateNest.ts
Normal file
12
resources/scripts/api/admin/nests/updateNest.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Nest, rawDataToNest } from '@/api/admin/nests/getNests';
|
||||||
|
|
||||||
|
export default (id: number, name: string, description: string | null, include: string[] = []): Promise<Nest> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/nests/${id}`, {
|
||||||
|
name, description,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToNest(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
84
resources/scripts/api/admin/node.ts
Normal file
84
resources/scripts/api/admin/node.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index';
|
||||||
|
import { Location } from '@/api/admin/location';
|
||||||
|
import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
|
||||||
|
import { Transformers } from '@definitions/admin';
|
||||||
|
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<WithRelationships<Node, 'location'>> => {
|
||||||
|
const { data } = await http.get(`/api/application/nodes/${id}`, {
|
||||||
|
params: {
|
||||||
|
include: [ 'location' ],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return withRelationships(Transformers.toNode(data.data), 'location');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchNodes = async (params: QueryBuilderParams<'name'>): Promise<Node[]> => {
|
||||||
|
const { data } = await http.get('/api/application/nodes', {
|
||||||
|
params: withQueryBuilderParams(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.data.map(Transformers.toNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllocations = async (id: string | number, params?: QueryBuilderParams<'ip' | 'server_id'>): Promise<Allocation[]> => {
|
||||||
|
const { data } = await http.get(`/api/application/nodes/${id}/allocations`, {
|
||||||
|
params: withQueryBuilderParams(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.data.map(Transformers.toAllocation);
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
ip: string;
|
||||||
|
ports: number[];
|
||||||
|
alias?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (id: string | number, values: Values, include: string[] = []): Promise<Allocation[]> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post(`/api/application/nodes/${id}/allocations`, values, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve((data || []).map(rawDataToAllocation)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (nodeId: number, allocationId: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/nodes/${nodeId}/allocations/${allocationId}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations';
|
||||||
|
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
ip?: string;
|
||||||
|
port?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (id: number, include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Allocation>>([ 'allocations', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get(`/api/application/nodes/${id}/allocations`, { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToAllocation),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
42
resources/scripts/api/admin/nodes/createNode.ts
Normal file
42
resources/scripts/api/admin/nodes/createNode.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
name: string;
|
||||||
|
locationId: number;
|
||||||
|
databaseHostId: number | null;
|
||||||
|
fqdn: string;
|
||||||
|
scheme: string;
|
||||||
|
behindProxy: boolean;
|
||||||
|
public: boolean;
|
||||||
|
daemonBase: string;
|
||||||
|
|
||||||
|
memory: number;
|
||||||
|
memoryOverallocate: number;
|
||||||
|
disk: number;
|
||||||
|
diskOverallocate: number;
|
||||||
|
|
||||||
|
listenPortHTTP: number;
|
||||||
|
publicPortHTTP: number;
|
||||||
|
listenPortSFTP: number;
|
||||||
|
publicPortSFTP: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (values: Values, include: string[] = []): Promise<Node> => {
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
Object.keys(values).forEach((key) => {
|
||||||
|
const key2 = key
|
||||||
|
.replace('HTTP', 'Http')
|
||||||
|
.replace('SFTP', 'Sftp')
|
||||||
|
.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
// @ts-ignore
|
||||||
|
data[key2] = values[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/nodes', data, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToNode(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/nodes/deleteNode.ts
Normal file
9
resources/scripts/api/admin/nodes/deleteNode.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/nodes/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
61
resources/scripts/api/admin/nodes/getAllocations.ts
Normal file
61
resources/scripts/api/admin/nodes/getAllocations.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import http, { FractalResponseData } from '@/api/http';
|
||||||
|
import { rawDataToServer, Server } from '@/api/admin/servers/getServers';
|
||||||
|
|
||||||
|
export interface Allocation {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
alias: string | null;
|
||||||
|
serverId: number | null;
|
||||||
|
assigned: boolean;
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
server?: Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText (): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToAllocation = ({ attributes }: FractalResponseData): Allocation => ({
|
||||||
|
id: attributes.id,
|
||||||
|
ip: attributes.ip,
|
||||||
|
port: attributes.port,
|
||||||
|
alias: attributes.alias || null,
|
||||||
|
serverId: attributes.server_id,
|
||||||
|
assigned: attributes.assigned,
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
server: attributes.relationships?.server?.object === 'server' ? rawDataToServer(attributes.relationships.server as FractalResponseData) : undefined,
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO: If IP is an IPv6, wrap IP in [].
|
||||||
|
getDisplayText (): string {
|
||||||
|
if (attributes.alias !== null) {
|
||||||
|
return `${attributes.ip}:${attributes.port} (${attributes.alias})`;
|
||||||
|
}
|
||||||
|
return `${attributes.ip}:${attributes.port}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
ip?: string
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
server_id?: string;
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (id: string | number, filters: Filters = {}, include: string[] = []): Promise<Allocation[]> => {
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/nodes/${id}/allocations`, { params: { include: include.join(','), ...params } })
|
||||||
|
.then(({ data }) => resolve((data.data || []).map(rawDataToAllocation)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
10
resources/scripts/api/admin/nodes/getNode.ts
Normal file
10
resources/scripts/api/admin/nodes/getNode.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes';
|
||||||
|
|
||||||
|
export default (id: number, include: string[] = []): Promise<Node> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/nodes/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToNode(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/nodes/${id}/configuration?format=yaml`)
|
||||||
|
.then(({ data }) => resolve(data))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
19
resources/scripts/api/admin/nodes/getNodeInformation.ts
Normal file
19
resources/scripts/api/admin/nodes/getNodeInformation.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export interface NodeInformation {
|
||||||
|
version: string;
|
||||||
|
system: {
|
||||||
|
type: string;
|
||||||
|
arch: string;
|
||||||
|
release: string;
|
||||||
|
cpus: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (id: number): Promise<NodeInformation> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/nodes/${id}/information`)
|
||||||
|
.then(({ data }) => resolve(data))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
107
resources/scripts/api/admin/nodes/getNodes.ts
Normal file
107
resources/scripts/api/admin/nodes/getNodes.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases';
|
||||||
|
import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations';
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
public: boolean;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
locationId: number;
|
||||||
|
databaseHostId: number | null;
|
||||||
|
fqdn: string;
|
||||||
|
listenPortHTTP: number;
|
||||||
|
publicPortHTTP: number;
|
||||||
|
listenPortSFTP: number;
|
||||||
|
publicPortSFTP: number;
|
||||||
|
scheme: string;
|
||||||
|
behindProxy: boolean;
|
||||||
|
maintenanceMode: boolean;
|
||||||
|
memory: number;
|
||||||
|
memoryOverallocate: number;
|
||||||
|
disk: number;
|
||||||
|
diskOverallocate: number;
|
||||||
|
uploadSize: number;
|
||||||
|
daemonBase: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
databaseHost: Database | undefined;
|
||||||
|
location: Location | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToNode = ({ attributes }: FractalResponseData): Node => ({
|
||||||
|
id: attributes.id,
|
||||||
|
uuid: attributes.uuid,
|
||||||
|
public: attributes.public,
|
||||||
|
name: attributes.name,
|
||||||
|
description: attributes.description,
|
||||||
|
locationId: attributes.location_id,
|
||||||
|
databaseHostId: attributes.database_host_id,
|
||||||
|
fqdn: attributes.fqdn,
|
||||||
|
listenPortHTTP: attributes.listen_port_http,
|
||||||
|
publicPortHTTP: attributes.public_port_http,
|
||||||
|
listenPortSFTP: attributes.listen_port_sftp,
|
||||||
|
publicPortSFTP: attributes.public_port_sftp,
|
||||||
|
scheme: attributes.scheme,
|
||||||
|
behindProxy: attributes.behind_proxy,
|
||||||
|
maintenanceMode: attributes.maintenance_mode,
|
||||||
|
memory: attributes.memory,
|
||||||
|
memoryOverallocate: attributes.memory_overallocate,
|
||||||
|
disk: attributes.disk,
|
||||||
|
diskOverallocate: attributes.disk_overallocate,
|
||||||
|
uploadSize: attributes.upload_size,
|
||||||
|
daemonBase: attributes.daemon_base,
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
databaseHost: attributes.relationships?.database_host !== undefined && attributes.relationships?.database_host.object !== 'null_resource' ? rawDataToDatabase(attributes.relationships.database_host as FractalResponseData) : undefined,
|
||||||
|
location: attributes.relationships?.location !== undefined ? rawDataToLocation(attributes.relationships.location as FractalResponseData) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
uuid?: string;
|
||||||
|
name?: string;
|
||||||
|
image?: string;
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
external_id?: string;
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Node>>([ 'nodes', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/nodes', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToNode),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
21
resources/scripts/api/admin/nodes/updateNode.ts
Normal file
21
resources/scripts/api/admin/nodes/updateNode.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes';
|
||||||
|
|
||||||
|
export default (id: number, node: Partial<Node>, include: string[] = []): Promise<Node> => {
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
Object.keys(node).forEach((key) => {
|
||||||
|
const key2 = key
|
||||||
|
.replace('HTTP', 'Http')
|
||||||
|
.replace('SFTP', 'Sftp')
|
||||||
|
.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||||
|
// @ts-ignore
|
||||||
|
data[key2] = node[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/nodes/${id}`, data, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToNode(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
103
resources/scripts/api/admin/roles.ts
Normal file
103
resources/scripts/api/admin/roles.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { Transformers, UserRole } from '@definitions/admin';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin/index';
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
const createRole = (name: string, description: string | null, include: string[] = []): Promise<UserRole> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/roles', {
|
||||||
|
name, description,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(Transformers.toUserRole(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRole = (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/roles/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRole = (id: number, include: string[] = []): Promise<UserRole> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/roles/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(Transformers.toUserRole(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchRoles = (filters?: { name?: string }): Promise<UserRole[]> => {
|
||||||
|
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/roles', { params })
|
||||||
|
.then(response => resolve(
|
||||||
|
(response.data.data || []).map(Transformers.toUserRole)
|
||||||
|
))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRole = (id: number, name: string, description: string | null, include: string[] = []): Promise<UserRole> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/roles/${id}`, {
|
||||||
|
name, description,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(Transformers.toUserRole(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoles = (include: string[] = []) => {
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
return useSWR<PaginatedResult<UserRole>>([ 'roles', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/roles', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(Transformers.toUserRole),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createRole,
|
||||||
|
deleteRole,
|
||||||
|
getRole,
|
||||||
|
searchRoles,
|
||||||
|
updateRole,
|
||||||
|
getRoles,
|
||||||
|
};
|
99
resources/scripts/api/admin/server.ts
Normal file
99
resources/scripts/api/admin/server.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import useSWR, { SWRResponse } from 'swr';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import http from '@/api/http';
|
||||||
|
import { Model, UUID, withRelationships, WithRelationships } from '@/api/admin/index';
|
||||||
|
import { Allocation, Node } from '@/api/admin/node';
|
||||||
|
import { Transformers, User } from '@definitions/admin';
|
||||||
|
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 | null;
|
||||||
|
image: string;
|
||||||
|
environment: Record<string, string>;
|
||||||
|
};
|
||||||
|
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<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: {
|
||||||
|
include: ['allocations', 'user', 'node', 'variables'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return withRelationships(Transformers.toServer(data), 'allocations', 'user', 'node', 'variables');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = useParams<'id'>();
|
||||||
|
|
||||||
|
return useSWR(`/api/application/servers/${params.id}`, async () => getServer(Number(params.id)), {
|
||||||
|
revalidateOnMount: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
};
|
80
resources/scripts/api/admin/servers/createServer.ts
Normal file
80
resources/scripts/api/admin/servers/createServer.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Server, rawDataToServer } from '@/api/admin/servers/getServers';
|
||||||
|
|
||||||
|
export interface CreateServerRequest {
|
||||||
|
externalId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
ownerId: number;
|
||||||
|
nodeId: number;
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
memory: number;
|
||||||
|
swap: number;
|
||||||
|
disk: number;
|
||||||
|
io: number;
|
||||||
|
cpu: number;
|
||||||
|
threads: string;
|
||||||
|
oomDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
featureLimits: {
|
||||||
|
allocations: number;
|
||||||
|
backups: number;
|
||||||
|
databases: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
allocation: {
|
||||||
|
default: number;
|
||||||
|
additional: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
startup: string;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
eggId: number;
|
||||||
|
image: string;
|
||||||
|
skipScripts: boolean;
|
||||||
|
startOnCompletion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (r: CreateServerRequest, include: string[] = []): Promise<Server> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/servers', {
|
||||||
|
externalId: r.externalId,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
owner_id: r.ownerId,
|
||||||
|
node_id: r.nodeId,
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
cpu: r.limits.cpu,
|
||||||
|
disk: r.limits.disk,
|
||||||
|
io: r.limits.io,
|
||||||
|
memory: r.limits.memory,
|
||||||
|
swap: r.limits.swap,
|
||||||
|
threads: r.limits.threads,
|
||||||
|
oom_killer: r.limits.oomDisabled,
|
||||||
|
},
|
||||||
|
|
||||||
|
feature_limits: {
|
||||||
|
allocations: r.featureLimits.allocations,
|
||||||
|
backups: r.featureLimits.backups,
|
||||||
|
databases: r.featureLimits.databases,
|
||||||
|
},
|
||||||
|
|
||||||
|
allocation: {
|
||||||
|
default: r.allocation.default,
|
||||||
|
additional: r.allocation.additional,
|
||||||
|
},
|
||||||
|
|
||||||
|
startup: r.startup,
|
||||||
|
environment: r.environment,
|
||||||
|
egg_id: r.eggId,
|
||||||
|
image: r.image,
|
||||||
|
skip_scripts: r.skipScripts,
|
||||||
|
start_on_completion: r.startOnCompletion,
|
||||||
|
}, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToServer(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
9
resources/scripts/api/admin/servers/deleteServer.ts
Normal file
9
resources/scripts/api/admin/servers/deleteServer.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/servers/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
10
resources/scripts/api/admin/servers/getServer.ts
Normal file
10
resources/scripts/api/admin/servers/getServer.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Server, rawDataToServer } from '@/api/admin/servers/getServers';
|
||||||
|
|
||||||
|
export default (id: number, include: string[]): Promise<Server> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/servers/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(rawDataToServer(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
177
resources/scripts/api/admin/servers/getServers.ts
Normal file
177
resources/scripts/api/admin/servers/getServers.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { createContext } from '@/api/admin';
|
||||||
|
import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg';
|
||||||
|
import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes';
|
||||||
|
import { Transformers, User } from '@definitions/admin';
|
||||||
|
|
||||||
|
export interface ServerVariable {
|
||||||
|
id: number;
|
||||||
|
eggId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
envVariable: string;
|
||||||
|
defaultValue: string;
|
||||||
|
userViewable: boolean;
|
||||||
|
userEditable: boolean;
|
||||||
|
rules: string;
|
||||||
|
required: boolean;
|
||||||
|
serverValue: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({
|
||||||
|
id: attributes.id,
|
||||||
|
eggId: attributes.egg_id,
|
||||||
|
name: attributes.name,
|
||||||
|
description: attributes.description,
|
||||||
|
envVariable: attributes.env_variable,
|
||||||
|
defaultValue: attributes.default_value,
|
||||||
|
userViewable: attributes.user_viewable,
|
||||||
|
userEditable: attributes.user_editable,
|
||||||
|
rules: attributes.rules,
|
||||||
|
required: attributes.required,
|
||||||
|
serverValue: attributes.server_value,
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
id: number;
|
||||||
|
externalId: string | null
|
||||||
|
uuid: string;
|
||||||
|
identifier: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
memory: number;
|
||||||
|
swap: number;
|
||||||
|
disk: number;
|
||||||
|
io: number;
|
||||||
|
cpu: number;
|
||||||
|
threads: string | null;
|
||||||
|
oomDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
featureLimits: {
|
||||||
|
databases: number;
|
||||||
|
allocations: number;
|
||||||
|
backups: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerId: number;
|
||||||
|
nodeId: number;
|
||||||
|
allocationId: number;
|
||||||
|
nestId: number;
|
||||||
|
eggId: number;
|
||||||
|
|
||||||
|
container: {
|
||||||
|
startup: string;
|
||||||
|
image: string;
|
||||||
|
environment: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
allocations?: Allocation[];
|
||||||
|
egg?: Egg;
|
||||||
|
node?: Node;
|
||||||
|
user?: User;
|
||||||
|
variables: ServerVariable[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawDataToServer = ({ attributes }: FractalResponseData): Server => ({
|
||||||
|
id: attributes.id,
|
||||||
|
externalId: attributes.external_id,
|
||||||
|
uuid: attributes.uuid,
|
||||||
|
identifier: attributes.identifier,
|
||||||
|
name: attributes.name,
|
||||||
|
description: attributes.description,
|
||||||
|
status: attributes.status,
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
memory: attributes.limits.memory,
|
||||||
|
swap: attributes.limits.swap,
|
||||||
|
disk: attributes.limits.disk,
|
||||||
|
io: attributes.limits.io,
|
||||||
|
cpu: attributes.limits.cpu,
|
||||||
|
threads: attributes.limits.threads,
|
||||||
|
oomDisabled: attributes.limits.oom_disabled,
|
||||||
|
},
|
||||||
|
|
||||||
|
featureLimits: {
|
||||||
|
databases: attributes.feature_limits.databases,
|
||||||
|
allocations: attributes.feature_limits.allocations,
|
||||||
|
backups: attributes.feature_limits.backups,
|
||||||
|
},
|
||||||
|
|
||||||
|
ownerId: attributes.owner_id,
|
||||||
|
nodeId: attributes.node_id,
|
||||||
|
allocationId: attributes.allocation_id,
|
||||||
|
nestId: attributes.nest_id,
|
||||||
|
eggId: attributes.egg_id,
|
||||||
|
|
||||||
|
container: {
|
||||||
|
startup: attributes.container.startup,
|
||||||
|
image: attributes.container.image,
|
||||||
|
environment: attributes.container.environment,
|
||||||
|
},
|
||||||
|
|
||||||
|
createdAt: new Date(attributes.created_at),
|
||||||
|
updatedAt: new Date(attributes.updated_at),
|
||||||
|
|
||||||
|
relations: {
|
||||||
|
allocations: ((attributes.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToAllocation),
|
||||||
|
egg: attributes.relationships?.egg?.object === 'egg' ? rawDataToEgg(attributes.relationships.egg as FractalResponseData) : undefined,
|
||||||
|
node: attributes.relationships?.node?.object === 'node' ? rawDataToNode(attributes.relationships.node as FractalResponseData) : undefined,
|
||||||
|
user: attributes.relationships?.user?.object === 'user' ? Transformers.toUser(attributes.relationships.user as FractalResponseData) : undefined,
|
||||||
|
variables: ((attributes.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerVariable),
|
||||||
|
},
|
||||||
|
}) as Server;
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
id?: string;
|
||||||
|
uuid?: string;
|
||||||
|
name?: string;
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
owner_id?: string;
|
||||||
|
node_id?: string;
|
||||||
|
external_id?: string;
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContext<Filters>();
|
||||||
|
|
||||||
|
export default (include: string[] = []) => {
|
||||||
|
const { page, filters, sort, sortDirection } = useContext(Context);
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (filters !== null) {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
// @ts-ignore
|
||||||
|
params['filter[' + key + ']'] = filters[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
params.sort = (sortDirection ? '-' : '') + sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return useSWR<PaginatedResult<Server>>([ 'servers', page, filters, sort, sortDirection ], async () => {
|
||||||
|
const { data } = await http.get('/api/application/servers', { params: { include: include.join(','), page, ...params } });
|
||||||
|
|
||||||
|
return ({
|
||||||
|
items: (data.data || []).map(rawDataToServer),
|
||||||
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
64
resources/scripts/api/admin/servers/updateServer.ts
Normal file
64
resources/scripts/api/admin/servers/updateServer.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Server, rawDataToServer } from '@/api/admin/servers/getServers';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
externalId: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: number;
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
memory: number;
|
||||||
|
swap: number;
|
||||||
|
disk: number;
|
||||||
|
io: number;
|
||||||
|
cpu: number;
|
||||||
|
threads: string;
|
||||||
|
oomDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
featureLimits: {
|
||||||
|
allocations: number;
|
||||||
|
backups: number;
|
||||||
|
databases: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocationId: number;
|
||||||
|
addAllocations: number[];
|
||||||
|
removeAllocations: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (id: number, server: Partial<Values>, include: string[] = []): Promise<Server> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(
|
||||||
|
`/api/application/servers/${id}`,
|
||||||
|
{
|
||||||
|
external_id: server.externalId,
|
||||||
|
name: server.name,
|
||||||
|
owner_id: server.ownerId,
|
||||||
|
|
||||||
|
limits: {
|
||||||
|
memory: server.limits?.memory,
|
||||||
|
swap: server.limits?.swap,
|
||||||
|
disk: server.limits?.disk,
|
||||||
|
io: server.limits?.io,
|
||||||
|
cpu: server.limits?.cpu,
|
||||||
|
threads: server.limits?.threads,
|
||||||
|
oom_killer: server.limits?.oomDisabled,
|
||||||
|
},
|
||||||
|
|
||||||
|
feature_limits: {
|
||||||
|
allocations: server.featureLimits?.allocations,
|
||||||
|
backups: server.featureLimits?.backups,
|
||||||
|
databases: server.featureLimits?.databases,
|
||||||
|
},
|
||||||
|
|
||||||
|
allocation_id: server.allocationId,
|
||||||
|
add_allocations: server.addAllocations,
|
||||||
|
remove_allocations: server.removeAllocations,
|
||||||
|
},
|
||||||
|
{ params: { include: include.join(',') } }
|
||||||
|
)
|
||||||
|
.then(({ data }) => resolve(rawDataToServer(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
28
resources/scripts/api/admin/servers/updateServerStartup.ts
Normal file
28
resources/scripts/api/admin/servers/updateServerStartup.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import http from '@/api/http';
|
||||||
|
import { Server, rawDataToServer } from '@/api/admin/servers/getServers';
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
startup: string;
|
||||||
|
environment: Record<string, any>;
|
||||||
|
eggId: number;
|
||||||
|
image: string;
|
||||||
|
skipScripts: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (id: number, values: Partial<Values>, include: string[] = []): Promise<Server> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(
|
||||||
|
`/api/application/servers/${id}/startup`,
|
||||||
|
{
|
||||||
|
startup: values.startup !== '' ? values.startup : null,
|
||||||
|
environment: values.environment,
|
||||||
|
egg_id: values.eggId,
|
||||||
|
image: values.image,
|
||||||
|
skip_scripts: values.skipScripts,
|
||||||
|
},
|
||||||
|
{ params: { include: include.join(',') } }
|
||||||
|
)
|
||||||
|
.then(({ data }) => resolve(rawDataToServer(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
96
resources/scripts/api/admin/users.ts
Normal file
96
resources/scripts/api/admin/users.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import http, {
|
||||||
|
FractalPaginatedResponse,
|
||||||
|
PaginatedResult,
|
||||||
|
QueryBuilderParams,
|
||||||
|
getPaginationSet,
|
||||||
|
withQueryBuilderParams,
|
||||||
|
} from '@/api/http';
|
||||||
|
import { Transformers, User } from '@definitions/admin';
|
||||||
|
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
export interface UpdateUserValues {
|
||||||
|
externalId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
adminRoleId: number | null;
|
||||||
|
rootAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = ['id', 'uuid', 'external_id', 'username', 'email'] as const;
|
||||||
|
type Filters = typeof filters[number];
|
||||||
|
|
||||||
|
const useGetUsers = (
|
||||||
|
params?: QueryBuilderParams<Filters>,
|
||||||
|
config?: SWRConfiguration,
|
||||||
|
): SWRResponse<PaginatedResult<User>, AxiosError> => {
|
||||||
|
return useSWR<PaginatedResult<User>>(
|
||||||
|
['/api/application/users', JSON.stringify(params)],
|
||||||
|
async () => {
|
||||||
|
const { data } = await http.get<FractalPaginatedResponse>('/api/application/users', {
|
||||||
|
params: withQueryBuilderParams(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return getPaginationSet(data, Transformers.toUser);
|
||||||
|
},
|
||||||
|
config || { revalidateOnMount: true, revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUser = (id: number, include: string[] = []): Promise<User> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`/api/application/users/${id}`, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(Transformers.toUser(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchUserAccounts = async (params: QueryBuilderParams<'username' | 'email'>): Promise<User[]> => {
|
||||||
|
const { data } = await http.get('/api/application/users', {
|
||||||
|
params: withQueryBuilderParams(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.data.map(Transformers.toUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUser = (values: UpdateUserValues, include: string[] = []): Promise<User> => {
|
||||||
|
const data = {};
|
||||||
|
Object.keys(values).forEach(k => {
|
||||||
|
// @ts-ignore
|
||||||
|
data[k.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`)] = values[k];
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post('/api/application/users', data, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(Transformers.toUser(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = (id: number, values: Partial<UpdateUserValues>, include: string[] = []): Promise<User> => {
|
||||||
|
const data = {};
|
||||||
|
Object.keys(values).forEach(k => {
|
||||||
|
// Don't set password if it is empty.
|
||||||
|
if (k === 'password' && values[k] === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
data[k.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`)] = values[k];
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.patch(`/api/application/users/${id}`, data, { params: { include: include.join(',') } })
|
||||||
|
.then(({ data }) => resolve(Transformers.toUser(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUser = (id: number): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.delete(`/api/application/users/${id}`)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useGetUsers, getUser, searchUserAccounts, createUser, updateUser, deleteUser };
|
2
resources/scripts/api/definitions/admin/index.ts
Normal file
2
resources/scripts/api/definitions/admin/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './models.d';
|
||||||
|
export { default as Transformers } from './transformers';
|
29
resources/scripts/api/definitions/admin/models.d.ts
vendored
Normal file
29
resources/scripts/api/definitions/admin/models.d.ts
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { ModelWithRelationships, UUID } from '@/api/definitions';
|
||||||
|
import { Server } from '@/api/admin/server';
|
||||||
|
|
||||||
|
interface User extends ModelWithRelationships {
|
||||||
|
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;
|
||||||
|
// TODO: just use an API call, this is probably a bad idea for performance.
|
||||||
|
servers?: Server[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserRole extends ModelWithRelationships {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
212
resources/scripts/api/definitions/admin/transformers.ts
Normal file
212
resources/scripts/api/definitions/admin/transformers.ts
Normal file
@ -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 * as Models from '@definitions/admin/models';
|
||||||
|
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<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 default class Transformers {
|
||||||
|
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): Models.UserRole => ({
|
||||||
|
id: attributes.id,
|
||||||
|
name: attributes.name,
|
||||||
|
description: attributes.description,
|
||||||
|
relationships: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
static toUser = ({ attributes }: FractalResponseData): Models.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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -11,10 +11,12 @@ import Spinner from '@/components/elements/Spinner';
|
|||||||
import { store } from '@/state';
|
import { store } from '@/state';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { SiteSettings } from '@/state/settings';
|
import { SiteSettings } from '@/state/settings';
|
||||||
|
import { AdminContext } from '@/state/admin';
|
||||||
|
|
||||||
|
const AdminRouter = lazy(() => import('@/routers/AdminRouter'));
|
||||||
|
const AuthenticationRouter = lazy(() => import('@/routers/AuthenticationRouter'));
|
||||||
const DashboardRouter = lazy(() => import('@/routers/DashboardRouter'));
|
const DashboardRouter = lazy(() => import('@/routers/DashboardRouter'));
|
||||||
const ServerRouter = lazy(() => import('@/routers/ServerRouter'));
|
const ServerRouter = lazy(() => import('@/routers/ServerRouter'));
|
||||||
const AuthenticationRouter = lazy(() => import('@/routers/AuthenticationRouter'));
|
|
||||||
|
|
||||||
interface ExtendedWindow extends Window {
|
interface ExtendedWindow extends Window {
|
||||||
SiteConfiguration?: SiteSettings;
|
SiteConfiguration?: SiteSettings;
|
||||||
@ -86,6 +88,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/admin/*"
|
||||||
|
element={
|
||||||
|
<Spinner.Suspense>
|
||||||
|
<AdminContext.Provider>
|
||||||
|
<AdminRouter />
|
||||||
|
</AdminContext.Provider>
|
||||||
|
</Spinner.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/*"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
|
36
resources/scripts/components/admin/AdminBox.tsx
Normal file
36
resources/scripts/components/admin/AdminBox.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon?: IconProp;
|
||||||
|
isLoading?: boolean;
|
||||||
|
title: string | ReactNode;
|
||||||
|
className?: string;
|
||||||
|
noPadding?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
button?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminBox = ({ icon, title, className, isLoading, children, button, noPadding }: Props) => (
|
||||||
|
<div css={tw`relative rounded shadow-md bg-neutral-700`} className={className}>
|
||||||
|
<SpinnerOverlay visible={isLoading || false} />
|
||||||
|
<div css={tw`flex flex-row bg-neutral-900 rounded-t px-4 xl:px-5 py-3 border-b border-black`}>
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<p css={tw`text-sm uppercase`}>
|
||||||
|
{icon && <FontAwesomeIcon icon={icon} css={tw`mr-2 text-neutral-300`} />}
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
{button}
|
||||||
|
</div>
|
||||||
|
<div css={[!noPadding && tw`px-4 xl:px-5 py-5`]}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminBox;
|
36
resources/scripts/components/admin/AdminCheckbox.tsx
Normal file
36
resources/scripts/components/admin/AdminCheckbox.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import tw, { styled } from 'twin.macro';
|
||||||
|
|
||||||
|
import Input from '@/components/elements/Input';
|
||||||
|
|
||||||
|
export const TableCheckbox = styled(Input)`
|
||||||
|
&& {
|
||||||
|
${tw`border-neutral-500 bg-transparent`};
|
||||||
|
|
||||||
|
&:not(:checked) {
|
||||||
|
${tw`hover:border-neutral-300`};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default ({
|
||||||
|
name,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange(e: ChangeEvent<HTMLInputElement>): void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div css={tw`flex items-center`}>
|
||||||
|
<TableCheckbox
|
||||||
|
type={'checkbox'}
|
||||||
|
name={'selectedItems'}
|
||||||
|
value={name}
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
42
resources/scripts/components/admin/AdminContentBlock.tsx
Normal file
42
resources/scripts/components/admin/AdminContentBlock.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
// import { CSSTransition } from 'react-transition-group';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
|
||||||
|
const AdminContentBlock: React.FC<{
|
||||||
|
children: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
showFlashKey?: string;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ children, title, showFlashKey }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = `Admin | ${title}`;
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// <CSSTransition timeout={150} classNames={'fade'} appear in>
|
||||||
|
<>
|
||||||
|
{showFlashKey && <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`} />}
|
||||||
|
{children}
|
||||||
|
{/* <p css={tw`text-center text-neutral-500 text-xs mt-4`}>
|
||||||
|
© 2015 - 2021
|
||||||
|
<a
|
||||||
|
rel={'noopener nofollow noreferrer'}
|
||||||
|
href={'https://pterodactyl.io'}
|
||||||
|
target={'_blank'}
|
||||||
|
css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
|
||||||
|
>
|
||||||
|
Pterodactyl Software
|
||||||
|
</a>
|
||||||
|
</p> */}
|
||||||
|
</>
|
||||||
|
// </CSSTransition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminContentBlock;
|
348
resources/scripts/components/admin/AdminTable.tsx
Normal file
348
resources/scripts/components/admin/AdminTable.tsx
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import { debounce } from 'debounce';
|
||||||
|
import type { ChangeEvent, MouseEvent, ReactNode } from 'react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import tw, { styled } from 'twin.macro';
|
||||||
|
|
||||||
|
import type { ListContext as TableHooks } from '@/api/admin';
|
||||||
|
import type { PaginatedResult, PaginationDataSet } from '@/api/http';
|
||||||
|
import { TableCheckbox } from '@/components/admin/AdminCheckbox';
|
||||||
|
import Input from '@/components/elements/Input';
|
||||||
|
import InputSpinner from '@/components/elements/InputSpinner';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
|
||||||
|
export function useTableHooks<T>(initialState?: T | (() => T)): TableHooks<T> {
|
||||||
|
const [page, setPage] = useState<number>(1);
|
||||||
|
const [filters, setFilters] = useState<T | null>(initialState || null);
|
||||||
|
const [sort, setSortState] = useState<string | null>(null);
|
||||||
|
const [sortDirection, setSortDirection] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const setSort = (newSort: string | null) => {
|
||||||
|
if (sort === newSort) {
|
||||||
|
setSortDirection(!sortDirection);
|
||||||
|
} else {
|
||||||
|
setSortState(newSort);
|
||||||
|
setSortDirection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableHeader = ({
|
||||||
|
name,
|
||||||
|
onClick,
|
||||||
|
direction,
|
||||||
|
}: {
|
||||||
|
name?: string;
|
||||||
|
onClick?: (e: MouseEvent) => void;
|
||||||
|
direction?: number | null;
|
||||||
|
}) => {
|
||||||
|
if (!name) {
|
||||||
|
return <th css={tw`px-6 py-2`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th css={tw`px-6 py-2`} onClick={onClick}>
|
||||||
|
<span css={tw`flex flex-row items-center cursor-pointer`}>
|
||||||
|
<span
|
||||||
|
css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap select-none`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{direction !== undefined ? (
|
||||||
|
<div css={tw`ml-1`}>
|
||||||
|
<svg fill="none" viewBox="0 0 20 20" css={tw`w-4 h-4 text-neutral-400`}>
|
||||||
|
{direction === null || direction === 1 ? (
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13 7L10 4L7 7"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{direction === null || direction === 2 ? (
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7 13L10 16L13 13"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TableHead = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<thead css={tw`bg-neutral-900 border-t border-b border-neutral-500`}>
|
||||||
|
<tr>
|
||||||
|
<TableHeader />
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TableBody = ({ children }: { children: ReactNode }) => {
|
||||||
|
return <tbody>{children}</tbody>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TableRow = ({ children }: { children: ReactNode }) => {
|
||||||
|
return <tr css={tw`h-12 hover:bg-neutral-600`}>{children}</tr>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
data?: PaginatedResult<T>;
|
||||||
|
onPageSelect: (page: number) => void;
|
||||||
|
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationButton = styled.button<{ active?: boolean }>`
|
||||||
|
${tw`relative items-center px-3 py-1 -ml-px text-sm font-normal leading-5 transition duration-150 ease-in-out border border-neutral-500 focus:z-10 focus:outline-none focus:border-primary-300 inline-flex`};
|
||||||
|
|
||||||
|
${props =>
|
||||||
|
props.active ? tw`bg-neutral-500 text-neutral-50` : tw`bg-neutral-600 text-neutral-200 hover:text-neutral-50`};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PaginationArrow = styled.button`
|
||||||
|
${tw`relative inline-flex items-center px-1 py-1 text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-600 text-neutral-400 hover:text-neutral-50 focus:z-10 focus:outline-none focus:border-primary-300`};
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
${tw`bg-neutral-700`}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:disabled {
|
||||||
|
${tw`text-neutral-400 cursor-default`};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function Pagination<T>({ data, onPageSelect, children }: Props<T>) {
|
||||||
|
let pagination: PaginationDataSet;
|
||||||
|
if (data === undefined) {
|
||||||
|
pagination = {
|
||||||
|
total: 0,
|
||||||
|
count: 0,
|
||||||
|
perPage: 0,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pagination = data.pagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPage = (page: number) => {
|
||||||
|
if (page < 1 || page > pagination.totalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageSelect(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFirstPage = pagination.currentPage === 1;
|
||||||
|
const isLastPage = pagination.currentPage >= pagination.totalPages;
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
if (pagination.totalPages < 7) {
|
||||||
|
for (let i = 1; i <= pagination.totalPages; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Don't ask me how this works, all I know is that this code will always have 7 items in the pagination,
|
||||||
|
// and keeps the current page centered if it is not too close to the start or end.
|
||||||
|
let start = Math.max(pagination.currentPage - 3, 1);
|
||||||
|
const end = Math.min(
|
||||||
|
pagination.totalPages,
|
||||||
|
pagination.currentPage + (pagination.currentPage < 4 ? 7 - pagination.currentPage : 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
while (start !== 1 && end - start !== 6) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<div css={tw`h-12 flex flex-row items-center w-full px-6 py-3 border-t border-neutral-500`}>
|
||||||
|
<p css={tw`text-sm leading-5 text-neutral-400`}>
|
||||||
|
Showing{' '}
|
||||||
|
<span css={tw`text-neutral-300`}>
|
||||||
|
{(pagination.currentPage - 1) * pagination.perPage + (pagination.total > 0 ? 1 : 0)}
|
||||||
|
</span>{' '}
|
||||||
|
to{' '}
|
||||||
|
<span css={tw`text-neutral-300`}>
|
||||||
|
{(pagination.currentPage - 1) * pagination.perPage + pagination.count}
|
||||||
|
</span>{' '}
|
||||||
|
of <span css={tw`text-neutral-300`}>{pagination.total}</span> results
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isFirstPage && isLastPage ? null : (
|
||||||
|
<div css={tw`flex flex-row ml-auto`}>
|
||||||
|
<nav css={tw`relative z-0 inline-flex shadow-sm`}>
|
||||||
|
<PaginationArrow
|
||||||
|
type="button"
|
||||||
|
css={tw`rounded-l-md`}
|
||||||
|
aria-label="Previous"
|
||||||
|
disabled={pagination.currentPage === 1}
|
||||||
|
onClick={() => setPage(pagination.currentPage - 1)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
css={tw`w-5 h-5`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</PaginationArrow>
|
||||||
|
|
||||||
|
{pages.map(page => (
|
||||||
|
<PaginationButton
|
||||||
|
key={page}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(page)}
|
||||||
|
active={pagination.currentPage === page}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationButton>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationArrow
|
||||||
|
type="button"
|
||||||
|
css={tw`-ml-px rounded-r-md`}
|
||||||
|
aria-label="Next"
|
||||||
|
disabled={pagination.currentPage === pagination.totalPages}
|
||||||
|
onClick={() => setPage(pagination.currentPage + 1)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
css={tw`w-5 h-5`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</PaginationArrow>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading = () => {
|
||||||
|
return (
|
||||||
|
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '3rem' }}>
|
||||||
|
<Spinner size={'base'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoItems = ({ className }: { className?: string }) => {
|
||||||
|
return (
|
||||||
|
<div css={tw`w-full flex flex-col items-center justify-center py-6 px-8`} className={className}>
|
||||||
|
<div css={tw`h-48 flex`}>
|
||||||
|
<img src={'/assets/svgs/not_found.svg'} alt={'No Items'} css={tw`h-full select-none`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p css={tw`text-lg text-neutral-300 text-center font-normal sm:mt-8`}>
|
||||||
|
No items could be found, it's almost like they are hiding.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
checked: boolean;
|
||||||
|
onSelectAllClick: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSearch?: (query: string) => Promise<void>;
|
||||||
|
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContentWrapper = ({ checked, onSelectAllClick, onSearch, children }: Params) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
debounce((query: string) => {
|
||||||
|
if (onSearch === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
onSearch(query).then(() => setLoading(false));
|
||||||
|
}, 200),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div css={tw`flex flex-row items-center h-12 px-6`}>
|
||||||
|
<div css={tw`flex flex-row items-center`}>
|
||||||
|
<TableCheckbox type={'checkbox'} name={'selectAll'} checked={checked} onChange={onSelectAllClick} />
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
css={tw`w-4 h-4 ml-1 text-neutral-200`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex flex-row items-center ml-auto`}>
|
||||||
|
<InputSpinner visible={loading}>
|
||||||
|
<Input
|
||||||
|
value={inputText}
|
||||||
|
css={tw`h-8`}
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={e => {
|
||||||
|
setInputText(e.currentTarget.value);
|
||||||
|
search(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputSpinner>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div css={tw`flex flex-col w-full`}>
|
||||||
|
<div css={tw`rounded-lg shadow-md bg-neutral-700`}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
87
resources/scripts/components/admin/Sidebar.tsx
Normal file
87
resources/scripts/components/admin/Sidebar.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import tw, { css, styled } from 'twin.macro';
|
||||||
|
|
||||||
|
import { withSubComponents } from '@/components/helpers';
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
${tw`w-full flex flex-col px-4`};
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-4`};
|
||||||
|
${tw`hover:text-neutral-50`};
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
${tw`h-6 w-6 flex flex-shrink-0`};
|
||||||
|
}
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
${tw`text-neutral-50 bg-neutral-800 rounded`};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Section = styled.div`
|
||||||
|
${tw`h-[18px] font-header font-medium text-xs text-neutral-300 whitespace-nowrap uppercase ml-4 mb-1 select-none`};
|
||||||
|
|
||||||
|
&:not(:first-of-type) {
|
||||||
|
${tw`mt-4`};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const User = styled.div`
|
||||||
|
${tw`h-16 w-full flex items-center bg-neutral-700 justify-center`};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Sidebar = styled.div<{ $collapsed?: boolean }>`
|
||||||
|
${tw`h-screen hidden md:flex flex-col items-center flex-shrink-0 bg-neutral-900 overflow-x-hidden ease-linear`};
|
||||||
|
${tw`transition-[width] duration-150 ease-in`};
|
||||||
|
${tw`w-[17.5rem]`};
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-8`};
|
||||||
|
${tw`hover:text-neutral-50`};
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
${tw`transition-none h-6 w-6 flex flex-shrink-0`};
|
||||||
|
}
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
${props =>
|
||||||
|
props.$collapsed &&
|
||||||
|
css`
|
||||||
|
${tw`w-20`};
|
||||||
|
|
||||||
|
${Section} {
|
||||||
|
${tw`invisible`};
|
||||||
|
}
|
||||||
|
|
||||||
|
${Wrapper} {
|
||||||
|
${tw`px-5`};
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
${tw`justify-center px-0`};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
${tw`justify-center px-4`};
|
||||||
|
}
|
||||||
|
|
||||||
|
& > a > span,
|
||||||
|
${User} > div,
|
||||||
|
${User} > a,
|
||||||
|
${Wrapper} > a > span {
|
||||||
|
${tw`hidden`};
|
||||||
|
}
|
||||||
|
`};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default withSubComponents(Sidebar, { Section, Wrapper, User });
|
42
resources/scripts/components/admin/SubNavigation.tsx
Normal file
42
resources/scripts/components/admin/SubNavigation.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
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`};
|
||||||
|
|
||||||
|
& > a {
|
||||||
|
${tw`flex flex-row items-center h-full px-4 border-b text-neutral-300 text-base whitespace-nowrap border-transparent`};
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
${tw`w-6 h-6 mr-2`};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
${tw`text-primary-300 border-primary-300`};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
to: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsWithIcon extends Props {
|
||||||
|
icon: ComponentType;
|
||||||
|
children?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropsWithoutIcon extends Props {
|
||||||
|
icon?: never;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubNavigationLink = ({ to, name, icon: IconComponent, children }: PropsWithIcon | PropsWithoutIcon) => (
|
||||||
|
<NavLink to={to}>
|
||||||
|
{IconComponent ? <IconComponent /> : children}
|
||||||
|
{name}
|
||||||
|
</NavLink>
|
||||||
|
);
|
@ -0,0 +1,73 @@
|
|||||||
|
import { Actions, useStoreActions } from 'easy-peasy';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import deleteDatabase from '@/api/admin/databases/deleteDatabase';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
databaseId: number;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ databaseId, onDeleted }: Props) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
setLoading(true);
|
||||||
|
clearFlashes('database');
|
||||||
|
|
||||||
|
deleteDatabase(databaseId)
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'database', error });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={'Delete database host?'}
|
||||||
|
buttonText={'Yes, delete database host'}
|
||||||
|
onConfirmed={onDelete}
|
||||||
|
showSpinnerOverlay={loading}
|
||||||
|
onModalDismissed={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this database host? This action will delete all knowledge of databases
|
||||||
|
created on this host but not the databases themselves.
|
||||||
|
</ConfirmationModal>
|
||||||
|
|
||||||
|
<Button type={'button'} size={'xsmall'} color={'red'} onClick={() => setVisible(true)}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
css={tw`h-5 w-5`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,235 @@
|
|||||||
|
import type { Action, Actions } from 'easy-peasy';
|
||||||
|
import { action, createContextStore, useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { number, object, string } from 'yup';
|
||||||
|
|
||||||
|
import type { Database } from '@/api/admin/databases/getDatabases';
|
||||||
|
import getDatabase from '@/api/admin/databases/getDatabase';
|
||||||
|
import updateDatabase from '@/api/admin/databases/updateDatabase';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
import DatabaseDeleteButton from '@/components/admin/databases/DatabaseDeleteButton';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface ctx {
|
||||||
|
database: Database | undefined;
|
||||||
|
setDatabase: Action<ctx, Database | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContextStore<ctx>({
|
||||||
|
database: undefined,
|
||||||
|
|
||||||
|
setDatabase: action((state, payload) => {
|
||||||
|
state.database = payload;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Values {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Params {
|
||||||
|
title: string;
|
||||||
|
initialValues?: Values;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
|
||||||
|
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InformationContainer = ({ title, initialValues, children, onSubmit }: Params) => {
|
||||||
|
const submit = (values: Values, helpers: FormikHelpers<Values>) => {
|
||||||
|
onSubmit(values, helpers);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = {
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: 3306,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
name: string().required().max(191),
|
||||||
|
host: string().max(255),
|
||||||
|
port: number().min(2).max(65534),
|
||||||
|
username: string().min(1).max(32),
|
||||||
|
password: string(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<>
|
||||||
|
<AdminBox title={title} css={tw`relative`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Form css={tw`mb-0`}>
|
||||||
|
<div>
|
||||||
|
<Field id={'name'} name={'name'} label={'Name'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
|
||||||
|
<Field id={'host'} name={'host'} label={'Host'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
|
||||||
|
<Field id={'port'} name={'port'} label={'Port'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
|
||||||
|
<Field id={'username'} name={'username'} label={'Username'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
|
||||||
|
<Field
|
||||||
|
id={'password'}
|
||||||
|
name={'password'}
|
||||||
|
label={'Password'}
|
||||||
|
type={'password'}
|
||||||
|
placeholder={'••••••••'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
||||||
|
{children}
|
||||||
|
<div css={tw`flex ml-auto`}>
|
||||||
|
<Button type={'submit'} disabled={isSubmitting || !isValid}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AdminBox>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditInformationContainer = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const database = Context.useStoreState(state => state.database);
|
||||||
|
const setDatabase = Context.useStoreActions(actions => actions.setDatabase);
|
||||||
|
|
||||||
|
if (database === undefined) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = ({ name, host, port, username, password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('database');
|
||||||
|
|
||||||
|
updateDatabase(database.id, name, host, port, username, password || undefined)
|
||||||
|
.then(() => setDatabase({ ...database, name, host, port, username }))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'database', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InformationContainer
|
||||||
|
title={'Edit Database'}
|
||||||
|
initialValues={{
|
||||||
|
name: database.name,
|
||||||
|
host: database.host,
|
||||||
|
port: database.port,
|
||||||
|
username: database.username,
|
||||||
|
password: '',
|
||||||
|
}}
|
||||||
|
onSubmit={submit}
|
||||||
|
>
|
||||||
|
<div css={tw`flex`}>
|
||||||
|
<DatabaseDeleteButton databaseId={database.id} onDeleted={() => navigate('/admin/databases')} />
|
||||||
|
</div>
|
||||||
|
</InformationContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DatabaseEditContainer = () => {
|
||||||
|
const params = useParams<'id'>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const database = Context.useStoreState(state => state.database);
|
||||||
|
const setDatabase = Context.useStoreActions(actions => actions.setDatabase);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearFlashes('database');
|
||||||
|
|
||||||
|
getDatabase(Number(params.id))
|
||||||
|
.then(database => setDatabase(database))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'database', error });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || database === undefined) {
|
||||||
|
return (
|
||||||
|
<AdminContentBlock>
|
||||||
|
<FlashMessageRender byKey={'database'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
|
||||||
|
<Spinner size={'base'} />
|
||||||
|
</div>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Database - ' + database.name}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{database.name}</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
{database.getAddress()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'database'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<EditInformationContainer />
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<Context.Provider>
|
||||||
|
<DatabaseEditContainer />
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,194 @@
|
|||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { Filters } from '@/api/admin/databases/getDatabases';
|
||||||
|
import getDatabases, { Context as DatabasesContext } from '@/api/admin/databases/getDatabases';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import { AdminContext } from '@/state/admin';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import AdminCheckbox from '@/components/admin/AdminCheckbox';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import AdminTable, {
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Pagination,
|
||||||
|
Loading,
|
||||||
|
NoItems,
|
||||||
|
ContentWrapper,
|
||||||
|
useTableHooks,
|
||||||
|
} from '@/components/admin/AdminTable';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
|
||||||
|
const RowCheckbox = ({ id }: { id: number }) => {
|
||||||
|
const isChecked = AdminContext.useStoreState(state => state.databases.selectedDatabases.indexOf(id) >= 0);
|
||||||
|
const appendSelectedDatabase = AdminContext.useStoreActions(actions => actions.databases.appendSelectedDatabase);
|
||||||
|
const removeSelectedDatabase = AdminContext.useStoreActions(actions => actions.databases.removeSelectedDatabase);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminCheckbox
|
||||||
|
name={id.toString()}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
appendSelectedDatabase(id);
|
||||||
|
} else {
|
||||||
|
removeSelectedDatabase(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DatabasesContainer = () => {
|
||||||
|
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(DatabasesContext);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { data: databases, error, isValidating } = getDatabases();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
clearFlashes('databases');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndAddHttpError({ key: 'databases', error });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const length = databases?.items?.length || 0;
|
||||||
|
|
||||||
|
const setSelectedDatabases = AdminContext.useStoreActions(actions => actions.databases.setSelectedDatabases);
|
||||||
|
const selectedDatabasesLength = AdminContext.useStoreState(state => state.databases.selectedDatabases.length);
|
||||||
|
|
||||||
|
const onSelectAllClick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedDatabases(e.currentTarget.checked ? databases?.items?.map(database => database.id) || [] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setFilters(null);
|
||||||
|
} else {
|
||||||
|
setFilters({ name: query });
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedDatabases([]);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Databases'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Database Hosts</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
Database hosts that servers can have databases created on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto pl-4`}>
|
||||||
|
<NavLink to="/admin/databases/new">
|
||||||
|
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
|
||||||
|
New Database Host
|
||||||
|
</Button>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'databases'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<AdminTable>
|
||||||
|
<ContentWrapper
|
||||||
|
checked={selectedDatabasesLength === (length === 0 ? -1 : length)}
|
||||||
|
onSelectAllClick={onSelectAllClick}
|
||||||
|
onSearch={onSearch}
|
||||||
|
>
|
||||||
|
<Pagination data={databases} onPageSelect={setPage}>
|
||||||
|
<div css={tw`overflow-x-auto`}>
|
||||||
|
<table css={tw`w-full table-auto`}>
|
||||||
|
<TableHead>
|
||||||
|
<TableHeader
|
||||||
|
name={'ID'}
|
||||||
|
direction={sort === 'id' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('id')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Name'}
|
||||||
|
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('name')}
|
||||||
|
/>
|
||||||
|
<TableHeader name={'Address'} />
|
||||||
|
<TableHeader name={'Username'} />
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{databases !== undefined &&
|
||||||
|
!error &&
|
||||||
|
!isValidating &&
|
||||||
|
length > 0 &&
|
||||||
|
databases.items.map(database => (
|
||||||
|
<TableRow key={database.id}>
|
||||||
|
<td css={tw`pl-6`}>
|
||||||
|
<RowCheckbox id={database.id} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={database.id.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{database.id}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<NavLink
|
||||||
|
to={`/admin/databases/${database.id}`}
|
||||||
|
css={tw`text-primary-400 hover:text-primary-300`}
|
||||||
|
>
|
||||||
|
{database.name}
|
||||||
|
</NavLink>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={database.getAddress()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{database.getAddress()}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
{database.username}
|
||||||
|
</td>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{databases === undefined || (error && isValidating) ? (
|
||||||
|
<Loading />
|
||||||
|
) : length < 1 ? (
|
||||||
|
<NoItems />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Pagination>
|
||||||
|
</ContentWrapper>
|
||||||
|
</AdminTable>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const hooks = useTableHooks<Filters>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatabasesContext.Provider value={hooks}>
|
||||||
|
<DatabasesContainer />
|
||||||
|
</DatabasesContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,48 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import createDatabase from '@/api/admin/databases/createDatabase';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import { InformationContainer, Values } from '@/components/admin/databases/DatabaseEditContainer';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = ({ name, host, port, username, password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('database:create');
|
||||||
|
|
||||||
|
createDatabase(name, host, port, username, password)
|
||||||
|
.then(database => navigate(`/admin/databases/${database.id}`))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'database:create', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'New Database'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Database Host</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
Add a new database host to the panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'database:create'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<InformationContainer title={'Create Database'} onSubmit={submit} />
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,74 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import deleteLocation from '@/api/admin/locations/deleteLocation';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locationId: number;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ locationId, onDeleted }: Props) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
setLoading(true);
|
||||||
|
clearFlashes('location');
|
||||||
|
|
||||||
|
deleteLocation(locationId)
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'location', error });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={'Delete location?'}
|
||||||
|
buttonText={'Yes, delete location'}
|
||||||
|
onConfirmed={onDelete}
|
||||||
|
showSpinnerOverlay={loading}
|
||||||
|
onModalDismissed={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this location? You may only delete a location if no nodes are assigned
|
||||||
|
to it.
|
||||||
|
</ConfirmationModal>
|
||||||
|
|
||||||
|
<Button type={'button'} size={'xsmall'} color={'red'} onClick={() => setVisible(true)}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
css={tw`h-5 w-5`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,180 @@
|
|||||||
|
import type { Action, Actions } from 'easy-peasy';
|
||||||
|
import { action, createContextStore, useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
|
||||||
|
import type { Location } from '@/api/admin/locations/getLocations';
|
||||||
|
import getLocation from '@/api/admin/locations/getLocation';
|
||||||
|
import updateLocation from '@/api/admin/locations/updateLocation';
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import LocationDeleteButton from '@/components/admin/locations/LocationDeleteButton';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface ctx {
|
||||||
|
location: Location | undefined;
|
||||||
|
setLocation: Action<ctx, Location | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContextStore<ctx>({
|
||||||
|
location: undefined,
|
||||||
|
|
||||||
|
setLocation: action((state, payload) => {
|
||||||
|
state.location = payload;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
short: string;
|
||||||
|
long: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditInformationContainer = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const location = Context.useStoreState(state => state.location);
|
||||||
|
const setLocation = Context.useStoreActions(actions => actions.setLocation);
|
||||||
|
|
||||||
|
if (location === undefined) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = ({ short, long }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('location');
|
||||||
|
|
||||||
|
updateLocation(location.id, short, long)
|
||||||
|
.then(() => setLocation({ ...location, short, long }))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'location', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{
|
||||||
|
short: location.short,
|
||||||
|
long: location.long || '',
|
||||||
|
}}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
short: string().required().min(1),
|
||||||
|
long: string().max(255, ''),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<>
|
||||||
|
<AdminBox title={'Edit Location'} css={tw`relative`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Form css={tw`mb-0`}>
|
||||||
|
<div>
|
||||||
|
<Field id={'short'} name={'short'} label={'Short Name'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`mt-6`}>
|
||||||
|
<Field id={'long'} name={'long'} label={'Long Name'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
||||||
|
<div css={tw`flex`}>
|
||||||
|
<LocationDeleteButton
|
||||||
|
locationId={location.id}
|
||||||
|
onDeleted={() => navigate('/admin/locations')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto`}>
|
||||||
|
<Button type={'submit'} disabled={isSubmitting || !isValid}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AdminBox>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocationEditContainer = () => {
|
||||||
|
const params = useParams<'id'>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const location = Context.useStoreState(state => state.location);
|
||||||
|
const setLocation = Context.useStoreActions(actions => actions.setLocation);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearFlashes('location');
|
||||||
|
|
||||||
|
getLocation(Number(params.id))
|
||||||
|
.then(location => setLocation(location))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'location', error });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || location === undefined) {
|
||||||
|
return (
|
||||||
|
<AdminContentBlock>
|
||||||
|
<FlashMessageRender byKey={'location'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
|
||||||
|
<Spinner size={'base'} />
|
||||||
|
</div>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Location - ' + location.short}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{location.short}</h2>
|
||||||
|
{(location.long || '').length < 1 ? (
|
||||||
|
<p css={tw`text-base text-neutral-400`}>
|
||||||
|
<span css={tw`italic`}>No long name</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
{location.long}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'location'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<EditInformationContainer />
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<Context.Provider>
|
||||||
|
<LocationEditContainer />
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,186 @@
|
|||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { Filters } from '@/api/admin/locations/getLocations';
|
||||||
|
import getLocations, { Context as LocationsContext } from '@/api/admin/locations/getLocations';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import AdminCheckbox from '@/components/admin/AdminCheckbox';
|
||||||
|
import AdminTable, {
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Pagination,
|
||||||
|
Loading,
|
||||||
|
NoItems,
|
||||||
|
ContentWrapper,
|
||||||
|
useTableHooks,
|
||||||
|
} from '@/components/admin/AdminTable';
|
||||||
|
import NewLocationButton from '@/components/admin/locations/NewLocationButton';
|
||||||
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import { AdminContext } from '@/state/admin';
|
||||||
|
|
||||||
|
const RowCheckbox = ({ id }: { id: number }) => {
|
||||||
|
const isChecked = AdminContext.useStoreState(state => state.locations.selectedLocations.indexOf(id) >= 0);
|
||||||
|
const appendSelectedLocation = AdminContext.useStoreActions(actions => actions.locations.appendSelectedLocation);
|
||||||
|
const removeSelectedLocation = AdminContext.useStoreActions(actions => actions.locations.removeSelectedLocation);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminCheckbox
|
||||||
|
name={id.toString()}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
appendSelectedLocation(id);
|
||||||
|
} else {
|
||||||
|
removeSelectedLocation(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocationsContainer = () => {
|
||||||
|
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(LocationsContext);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { data: locations, error, isValidating } = getLocations();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
clearFlashes('locations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndAddHttpError({ key: 'locations', error });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const length = locations?.items?.length || 0;
|
||||||
|
|
||||||
|
const setSelectedLocations = AdminContext.useStoreActions(actions => actions.locations.setSelectedLocations);
|
||||||
|
const selectedLocationsLength = AdminContext.useStoreState(state => state.locations.selectedLocations.length);
|
||||||
|
|
||||||
|
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedLocations(e.currentTarget.checked ? locations?.items?.map(location => location.id) || [] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setFilters(null);
|
||||||
|
} else {
|
||||||
|
setFilters({ short: query });
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedLocations([]);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Locations'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Locations</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
All locations that nodes can be assigned to for easier categorization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto pl-4`}>
|
||||||
|
<NewLocationButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'locations'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<AdminTable>
|
||||||
|
<ContentWrapper
|
||||||
|
checked={selectedLocationsLength === (length === 0 ? -1 : length)}
|
||||||
|
onSelectAllClick={onSelectAllClick}
|
||||||
|
onSearch={onSearch}
|
||||||
|
>
|
||||||
|
<Pagination data={locations} onPageSelect={setPage}>
|
||||||
|
<div css={tw`overflow-x-auto`}>
|
||||||
|
<table css={tw`w-full table-auto`}>
|
||||||
|
<TableHead>
|
||||||
|
<TableHeader
|
||||||
|
name={'ID'}
|
||||||
|
direction={sort === 'id' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('id')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Short Name'}
|
||||||
|
direction={sort === 'short' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('short')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Long Name'}
|
||||||
|
direction={sort === 'long' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('long')}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{locations !== undefined &&
|
||||||
|
!error &&
|
||||||
|
!isValidating &&
|
||||||
|
length > 0 &&
|
||||||
|
locations.items.map(location => (
|
||||||
|
<TableRow key={location.id}>
|
||||||
|
<td css={tw`pl-6`}>
|
||||||
|
<RowCheckbox id={location.id} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={location.id.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{location.id}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<NavLink
|
||||||
|
to={`/admin/locations/${location.id}`}
|
||||||
|
css={tw`text-primary-400 hover:text-primary-300`}
|
||||||
|
>
|
||||||
|
{location.short}
|
||||||
|
</NavLink>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
{location.long}
|
||||||
|
</td>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{locations === undefined || (error && isValidating) ? (
|
||||||
|
<Loading />
|
||||||
|
) : length < 1 ? (
|
||||||
|
<NoItems />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Pagination>
|
||||||
|
</ContentWrapper>
|
||||||
|
</AdminTable>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const hooks = useTableHooks<Filters>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocationsContext.Provider value={hooks}>
|
||||||
|
<LocationsContainer />
|
||||||
|
</LocationsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,112 @@
|
|||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
|
||||||
|
import createLocation from '@/api/admin/locations/createLocation';
|
||||||
|
import getLocations from '@/api/admin/locations/getLocations';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import Modal from '@/components/elements/Modal';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
short: string;
|
||||||
|
long: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = object().shape({
|
||||||
|
short: string()
|
||||||
|
.required('A location short name must be provided.')
|
||||||
|
.max(32, 'Location short name must not exceed 32 characters.'),
|
||||||
|
long: string().max(255, 'Location long name must not exceed 255 characters.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { mutate } = getLocations();
|
||||||
|
|
||||||
|
const submit = ({ short, long }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('location:create');
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
createLocation(short, long)
|
||||||
|
.then(async location => {
|
||||||
|
await mutate(data => ({ ...data!, items: data!.items.concat(location) }), false);
|
||||||
|
setVisible(false);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearAndAddHttpError({ key: 'location:create', error });
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Formik onSubmit={submit} initialValues={{ short: '', long: '' }} validationSchema={schema}>
|
||||||
|
{({ isSubmitting, resetForm }) => (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
dismissable={!isSubmitting}
|
||||||
|
showSpinnerOverlay={isSubmitting}
|
||||||
|
onDismissed={() => {
|
||||||
|
resetForm();
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlashMessageRender byKey={'location:create'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Location</h2>
|
||||||
|
|
||||||
|
<Form css={tw`m-0`}>
|
||||||
|
<Field
|
||||||
|
type={'text'}
|
||||||
|
id={'short'}
|
||||||
|
name={'short'}
|
||||||
|
label={'Short'}
|
||||||
|
description={'A short name used to identify this location.'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div css={tw`mt-6`}>
|
||||||
|
<Field
|
||||||
|
type={'text'}
|
||||||
|
id={'long'}
|
||||||
|
name={'long'}
|
||||||
|
label={'Long'}
|
||||||
|
description={'A long name for this location.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex flex-wrap justify-end mt-6`}>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
isSecondary
|
||||||
|
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
|
||||||
|
Create Location
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
size={'large'}
|
||||||
|
css={tw`h-10 px-4 py-0 whitespace-nowrap`}
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
>
|
||||||
|
New Location
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,73 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import deleteMount from '@/api/admin/mounts/deleteMount';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mountId: number;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ mountId, onDeleted }: Props) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
setLoading(true);
|
||||||
|
clearFlashes('mount');
|
||||||
|
|
||||||
|
deleteMount(mountId)
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'mount', error });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={'Delete mount?'}
|
||||||
|
buttonText={'Yes, delete mount'}
|
||||||
|
onConfirmed={onDelete}
|
||||||
|
showSpinnerOverlay={loading}
|
||||||
|
onModalDismissed={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this mount? Deleting a mount will not delete files on any nodes.
|
||||||
|
</ConfirmationModal>
|
||||||
|
|
||||||
|
<Button type={'button'} size={'xsmall'} color={'red'} onClick={() => setVisible(true)}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
css={tw`h-5 w-5`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
142
resources/scripts/components/admin/mounts/MountEditContainer.tsx
Normal file
142
resources/scripts/components/admin/mounts/MountEditContainer.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import type { Action, Actions } from 'easy-peasy';
|
||||||
|
import { action, createContextStore, useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { Mount } from '@/api/admin/mounts/getMounts';
|
||||||
|
import getMount from '@/api/admin/mounts/getMount';
|
||||||
|
import updateMount from '@/api/admin/mounts/updateMount';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import MountDeleteButton from '@/components/admin/mounts/MountDeleteButton';
|
||||||
|
import MountForm from '@/components/admin/mounts/MountForm';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface ctx {
|
||||||
|
mount: Mount | undefined;
|
||||||
|
setMount: Action<ctx, Mount | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContextStore<ctx>({
|
||||||
|
mount: undefined,
|
||||||
|
|
||||||
|
setMount: action((state, payload) => {
|
||||||
|
state.mount = payload;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MountEditContainer = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useParams<'id'>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const mount = Context.useStoreState(state => state.mount);
|
||||||
|
const setMount = Context.useStoreActions(actions => actions.setMount);
|
||||||
|
|
||||||
|
const submit = (
|
||||||
|
{ name, description, source, target, readOnly, userMountable }: any,
|
||||||
|
{ setSubmitting }: FormikHelpers<any>,
|
||||||
|
) => {
|
||||||
|
if (mount === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFlashes('mount');
|
||||||
|
|
||||||
|
updateMount(mount.id, name, description, source, target, readOnly === '1', userMountable === '1')
|
||||||
|
.then(() =>
|
||||||
|
setMount({
|
||||||
|
...mount,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
readOnly: readOnly === '1',
|
||||||
|
userMountable: userMountable === '1',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'mount', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearFlashes('mount');
|
||||||
|
|
||||||
|
getMount(Number(params.id))
|
||||||
|
.then(mount => setMount(mount))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'mount', error });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || mount === undefined) {
|
||||||
|
return (
|
||||||
|
<AdminContentBlock>
|
||||||
|
<FlashMessageRender byKey={'mount'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
|
||||||
|
<Spinner size={'base'} />
|
||||||
|
</div>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Mount - ' + mount.name}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{mount.name}</h2>
|
||||||
|
{(mount.description || '').length < 1 ? (
|
||||||
|
<p css={tw`text-base text-neutral-400`}>
|
||||||
|
<span css={tw`italic`}>No description</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
{mount.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'mount'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<MountForm
|
||||||
|
action={'Save Changes'}
|
||||||
|
title={'Edit Mount'}
|
||||||
|
initialValues={{
|
||||||
|
name: mount.name,
|
||||||
|
description: mount.description || '',
|
||||||
|
source: mount.source,
|
||||||
|
target: mount.target,
|
||||||
|
readOnly: mount.readOnly ? '1' : '0',
|
||||||
|
userMountable: mount.userMountable ? '1' : '0',
|
||||||
|
}}
|
||||||
|
onSubmit={submit}
|
||||||
|
>
|
||||||
|
<div css={tw`flex`}>
|
||||||
|
<MountDeleteButton mountId={mount.id} onDeleted={() => navigate('/admin/mounts')} />
|
||||||
|
</div>
|
||||||
|
</MountForm>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<Context.Provider>
|
||||||
|
<MountEditContainer />
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
133
resources/scripts/components/admin/mounts/MountForm.tsx
Normal file
133
resources/scripts/components/admin/mounts/MountForm.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Field as FormikField, Form, Formik } from 'formik';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { boolean, object, string } from 'yup';
|
||||||
|
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import Label from '@/components/elements/Label';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
readOnly: string;
|
||||||
|
userMountable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
initialValues?: Values;
|
||||||
|
|
||||||
|
onSubmit: (values: Values, helpers: FormikHelpers<Values>) => void;
|
||||||
|
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MountForm({ action, title, initialValues, children, onSubmit }: Props) {
|
||||||
|
const submit = (values: Values, helpers: FormikHelpers<Values>) => {
|
||||||
|
onSubmit(values, helpers);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!initialValues) {
|
||||||
|
initialValues = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
source: '',
|
||||||
|
target: '',
|
||||||
|
readOnly: '0',
|
||||||
|
userMountable: '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
name: string().required().min(1),
|
||||||
|
description: string().max(255, ''),
|
||||||
|
source: string().max(255, ''),
|
||||||
|
target: string().max(255, ''),
|
||||||
|
readOnly: boolean(),
|
||||||
|
userMountable: boolean(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<AdminBox title={title} css={tw`relative`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Form css={tw`mb-0`}>
|
||||||
|
<div>
|
||||||
|
<Field id={'name'} name={'name'} label={'Name'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`mt-6`}>
|
||||||
|
<Field id={'description'} name={'description'} label={'Description'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
|
||||||
|
<Field id={'source'} name={'source'} label={'Source'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
|
||||||
|
<Field id={'target'} name={'target'} label={'Target'} type={'text'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-row mt-6`}>
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:mr-4 mt-6 md:mt-0`}>
|
||||||
|
<Label htmlFor={'readOnly'}>Permissions</Label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label css={tw`inline-flex items-center mr-2`}>
|
||||||
|
<FormikField name={'readOnly'} type={'radio'} value={'0'} />
|
||||||
|
<span css={tw`ml-2`}>Writable</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label css={tw`inline-flex items-center ml-2`}>
|
||||||
|
<FormikField name={'readOnly'} type={'radio'} value={'1'} />
|
||||||
|
<span css={tw`ml-2`}>Read Only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`md:w-full md:flex md:flex-col md:ml-4 mt-6 md:mt-0`}>
|
||||||
|
<Label htmlFor={'userMountable'}>User Mountable</Label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label css={tw`inline-flex items-center mr-2`}>
|
||||||
|
<FormikField name={'userMountable'} type={'radio'} value={'0'} />
|
||||||
|
<span css={tw`ml-2`}>Admin Only</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label css={tw`inline-flex items-center ml-2`}>
|
||||||
|
<FormikField name={'userMountable'} type={'radio'} value={'1'} />
|
||||||
|
<span css={tw`ml-2`}>Users</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto`}>
|
||||||
|
<Button type={'submit'} disabled={isSubmitting || !isValid}>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AdminBox>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MountForm;
|
241
resources/scripts/components/admin/mounts/MountsContainer.tsx
Normal file
241
resources/scripts/components/admin/mounts/MountsContainer.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { Filters } from '@/api/admin/mounts/getMounts';
|
||||||
|
import getMounts, { Context as MountsContext } from '@/api/admin/mounts/getMounts';
|
||||||
|
import AdminCheckbox from '@/components/admin/AdminCheckbox';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import AdminTable, {
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Pagination,
|
||||||
|
Loading,
|
||||||
|
NoItems,
|
||||||
|
ContentWrapper,
|
||||||
|
useTableHooks,
|
||||||
|
} from '@/components/admin/AdminTable';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import { AdminContext } from '@/state/admin';
|
||||||
|
|
||||||
|
const RowCheckbox = ({ id }: { id: number }) => {
|
||||||
|
const isChecked = AdminContext.useStoreState(state => state.mounts.selectedMounts.indexOf(id) >= 0);
|
||||||
|
const appendSelectedMount = AdminContext.useStoreActions(actions => actions.mounts.appendSelectedMount);
|
||||||
|
const removeSelectedMount = AdminContext.useStoreActions(actions => actions.mounts.removeSelectedMount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminCheckbox
|
||||||
|
name={id.toString()}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
appendSelectedMount(id);
|
||||||
|
} else {
|
||||||
|
removeSelectedMount(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MountsContainer = () => {
|
||||||
|
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(MountsContext);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { data: mounts, error, isValidating } = getMounts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
clearFlashes('mounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndAddHttpError({ key: 'mounts', error });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const length = mounts?.items?.length || 0;
|
||||||
|
|
||||||
|
const setSelectedMounts = AdminContext.useStoreActions(actions => actions.mounts.setSelectedMounts);
|
||||||
|
const selectedMountsLength = AdminContext.useStoreState(state => state.mounts.selectedMounts.length);
|
||||||
|
|
||||||
|
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedMounts(e.currentTarget.checked ? mounts?.items?.map(mount => mount.id) || [] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setFilters(null);
|
||||||
|
} else {
|
||||||
|
setFilters({ name: query });
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedMounts([]);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Mounts'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Mounts</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
Configure and manage additional mount points for servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto pl-4`}>
|
||||||
|
<NavLink to={`/admin/mounts/new`}>
|
||||||
|
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
|
||||||
|
New Mount
|
||||||
|
</Button>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'mounts'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<AdminTable>
|
||||||
|
<ContentWrapper
|
||||||
|
checked={selectedMountsLength === (length === 0 ? -1 : length)}
|
||||||
|
onSelectAllClick={onSelectAllClick}
|
||||||
|
onSearch={onSearch}
|
||||||
|
>
|
||||||
|
<Pagination data={mounts} onPageSelect={setPage}>
|
||||||
|
<div css={tw`overflow-x-auto`}>
|
||||||
|
<table css={tw`w-full table-auto`}>
|
||||||
|
<TableHead>
|
||||||
|
<TableHeader
|
||||||
|
name={'ID'}
|
||||||
|
direction={sort === 'id' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('id')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Name'}
|
||||||
|
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('name')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Source Path'}
|
||||||
|
direction={sort === 'source' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('source')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Target Path'}
|
||||||
|
direction={sort === 'target' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('target')}
|
||||||
|
/>
|
||||||
|
<th css={tw`px-6 py-2`} />
|
||||||
|
<th css={tw`px-6 py-2`} />
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{mounts !== undefined &&
|
||||||
|
!error &&
|
||||||
|
!isValidating &&
|
||||||
|
length > 0 &&
|
||||||
|
mounts.items.map(mount => (
|
||||||
|
<TableRow key={mount.id}>
|
||||||
|
<td css={tw`pl-6`}>
|
||||||
|
<RowCheckbox id={mount.id} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={mount.id.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{mount.id}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<NavLink
|
||||||
|
to={`/admin/mounts/${mount.id}`}
|
||||||
|
css={tw`text-primary-400 hover:text-primary-300`}
|
||||||
|
>
|
||||||
|
{mount.name}
|
||||||
|
</NavLink>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={mount.source.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{mount.source}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={mount.target.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{mount.target}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 whitespace-nowrap`}>
|
||||||
|
{mount.readOnly ? (
|
||||||
|
<span
|
||||||
|
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
|
||||||
|
>
|
||||||
|
Read Only
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
|
||||||
|
>
|
||||||
|
Writable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 whitespace-nowrap`}>
|
||||||
|
{mount.userMountable ? (
|
||||||
|
<span
|
||||||
|
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-green-100 text-green-800`}
|
||||||
|
>
|
||||||
|
Mountable
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
css={tw`px-2 inline-flex text-xs leading-5 font-medium rounded-full bg-yellow-200 text-yellow-800`}
|
||||||
|
>
|
||||||
|
Admin Only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{mounts === undefined || (error && isValidating) ? (
|
||||||
|
<Loading />
|
||||||
|
) : length < 1 ? (
|
||||||
|
<NoItems />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Pagination>
|
||||||
|
</ContentWrapper>
|
||||||
|
</AdminTable>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const hooks = useTableHooks<Filters>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MountsContext.Provider value={hooks}>
|
||||||
|
<MountsContainer />
|
||||||
|
</MountsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,51 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import MountForm from '@/components/admin/mounts/MountForm';
|
||||||
|
import createMount from '@/api/admin/mounts/createMount';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = (
|
||||||
|
{ name, description, source, target, readOnly, userMountable }: any,
|
||||||
|
{ setSubmitting }: FormikHelpers<any>,
|
||||||
|
) => {
|
||||||
|
clearFlashes('mount:create');
|
||||||
|
|
||||||
|
createMount(name, description, source, target, readOnly === '1', userMountable === '1')
|
||||||
|
.then(mount => navigate(`/admin/mounts/${mount.id}`))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'mount:create', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'New Mount'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Mount</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
Add a new mount to the panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'mount:create'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<MountForm action={'Create'} title={'Create Mount'} onSubmit={submit} />
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
82
resources/scripts/components/admin/nests/ImportEggButton.tsx
Normal file
82
resources/scripts/components/admin/nests/ImportEggButton.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import getEggs from '@/api/admin/nests/getEggs';
|
||||||
|
import importEgg from '@/api/admin/nests/importEgg';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
// import { Editor } from '@/components/elements/editor';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Modal from '@/components/elements/Modal';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
export default ({ className }: { className?: string }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
const { clearFlashes } = useFlash();
|
||||||
|
|
||||||
|
const params = useParams<'nestId'>();
|
||||||
|
const { mutate } = getEggs(Number(params.nestId));
|
||||||
|
|
||||||
|
let fetchFileContent: (() => Promise<string>) | null = null;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
clearFlashes('egg:import');
|
||||||
|
|
||||||
|
if (fetchFileContent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const egg = await importEgg(Number(params.nestId), await fetchFileContent());
|
||||||
|
await mutate(data => ({ ...data!, items: [...data!.items!, egg] }));
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
onDismissed={() => {
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlashMessageRender byKey={'egg:import'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>Import Egg</h2>
|
||||||
|
|
||||||
|
{/*<Editor*/}
|
||||||
|
{/* // overrides={tw`h-64 rounded`}*/}
|
||||||
|
{/* initialContent={''}*/}
|
||||||
|
{/* // language={jsonLanguage}*/}
|
||||||
|
{/* fetchContent={value => {*/}
|
||||||
|
{/* fetchFileContent = value;*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
|
||||||
|
<div css={tw`flex flex-wrap justify-end mt-4 sm:mt-6`}>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
isSecondary
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button css={tw`w-full sm:w-auto mt-4 sm:mt-0`} onClick={submit}>
|
||||||
|
Import Egg
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
size={'large'}
|
||||||
|
css={tw`h-10 px-4 py-0 whitespace-nowrap`}
|
||||||
|
className={className}
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
isSecondary
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,73 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import deleteNest from '@/api/admin/nests/deleteNest';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nestId: number;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ nestId, onDeleted }: Props) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
setLoading(true);
|
||||||
|
clearFlashes('nest');
|
||||||
|
|
||||||
|
deleteNest(nestId)
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'nest', error });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={'Delete nest?'}
|
||||||
|
buttonText={'Yes, delete nest'}
|
||||||
|
onConfirmed={onDelete}
|
||||||
|
showSpinnerOverlay={loading}
|
||||||
|
onModalDismissed={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this nest? Deleting a nest will delete all eggs assigned to it.
|
||||||
|
</ConfirmationModal>
|
||||||
|
|
||||||
|
<Button type={'button'} size={'xsmall'} color={'red'} onClick={() => setVisible(true)}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
css={tw`h-5 w-5`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
250
resources/scripts/components/admin/nests/NestEditContainer.tsx
Normal file
250
resources/scripts/components/admin/nests/NestEditContainer.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import type { Action, Actions } from 'easy-peasy';
|
||||||
|
import { action, createContextStore, useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { NavLink, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
|
||||||
|
import ImportEggButton from '@/components/admin/nests/ImportEggButton';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import type { Nest } from '@/api/admin/nests/getNests';
|
||||||
|
import getNest from '@/api/admin/nests/getNest';
|
||||||
|
import updateNest from '@/api/admin/nests/updateNest';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
import Input from '@/components/elements/Input';
|
||||||
|
import Label from '@/components/elements/Label';
|
||||||
|
import NestDeleteButton from '@/components/admin/nests/NestDeleteButton';
|
||||||
|
import NestEggTable from '@/components/admin/nests/NestEggTable';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface ctx {
|
||||||
|
nest: Nest | undefined;
|
||||||
|
setNest: Action<ctx, Nest | undefined>;
|
||||||
|
|
||||||
|
selectedEggs: number[];
|
||||||
|
|
||||||
|
setSelectedEggs: Action<ctx, number[]>;
|
||||||
|
appendSelectedEggs: Action<ctx, number>;
|
||||||
|
removeSelectedEggs: Action<ctx, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Context = createContextStore<ctx>({
|
||||||
|
nest: undefined,
|
||||||
|
|
||||||
|
setNest: action((state, payload) => {
|
||||||
|
state.nest = payload;
|
||||||
|
}),
|
||||||
|
|
||||||
|
selectedEggs: [],
|
||||||
|
|
||||||
|
setSelectedEggs: action((state, payload) => {
|
||||||
|
state.selectedEggs = payload;
|
||||||
|
}),
|
||||||
|
|
||||||
|
appendSelectedEggs: action((state, payload) => {
|
||||||
|
state.selectedEggs = state.selectedEggs.filter(id => id !== payload).concat(payload);
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeSelectedEggs: action((state, payload) => {
|
||||||
|
state.selectedEggs = state.selectedEggs.filter(id => id !== payload);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditInformationContainer = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nest = Context.useStoreState(state => state.nest);
|
||||||
|
const setNest = Context.useStoreActions(actions => actions.setNest);
|
||||||
|
|
||||||
|
if (nest === undefined) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('nest');
|
||||||
|
|
||||||
|
updateNest(nest.id, name, description)
|
||||||
|
.then(() => setNest({ ...nest, name, description }))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'nest', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{
|
||||||
|
name: nest.name,
|
||||||
|
description: nest.description || '',
|
||||||
|
}}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
name: string().required().min(1),
|
||||||
|
description: string().max(255, ''),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<>
|
||||||
|
<AdminBox title={'Edit Nest'} css={tw`flex-1 self-start w-full relative mb-8 lg:mb-0 mr-0 lg:mr-4`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Form>
|
||||||
|
<Field id={'name'} name={'name'} label={'Name'} type={'text'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<Field id={'description'} name={'description'} label={'Description'} type={'text'} />
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-row items-center mt-6`}>
|
||||||
|
<div css={tw`flex`}>
|
||||||
|
<NestDeleteButton nestId={nest.id} onDeleted={() => navigate('/admin/nests')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto`}>
|
||||||
|
<Button type={'submit'} disabled={isSubmitting || !isValid}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AdminBox>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ViewDetailsContainer = () => {
|
||||||
|
const nest = Context.useStoreState(state => state.nest);
|
||||||
|
|
||||||
|
if (nest === undefined) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox title={'Nest Details'} css={tw`flex-1 w-full relative ml-0 lg:ml-4`}>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Label>ID</Label>
|
||||||
|
<CopyOnClick text={nest.id.toString()}>
|
||||||
|
<Input type={'text'} value={nest.id} readOnly />
|
||||||
|
</CopyOnClick>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`mt-6`}>
|
||||||
|
<Label>UUID</Label>
|
||||||
|
<CopyOnClick text={nest.uuid}>
|
||||||
|
<Input type={'text'} value={nest.uuid} readOnly />
|
||||||
|
</CopyOnClick>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`mt-6 mb-2`}>
|
||||||
|
<Label>Author</Label>
|
||||||
|
<CopyOnClick text={nest.author}>
|
||||||
|
<Input type={'text'} value={nest.author} readOnly />
|
||||||
|
</CopyOnClick>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NestEditContainer = () => {
|
||||||
|
const params = useParams<'nestId'>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const nest = Context.useStoreState(state => state.nest);
|
||||||
|
const setNest = Context.useStoreActions(actions => actions.setNest);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearFlashes('nest');
|
||||||
|
|
||||||
|
getNest(Number(params.nestId), ['eggs'])
|
||||||
|
.then(nest => setNest(nest))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'nest', error });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || nest === undefined) {
|
||||||
|
return (
|
||||||
|
<AdminContentBlock>
|
||||||
|
<FlashMessageRender byKey={'nest'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
|
||||||
|
<Spinner size={'base'} />
|
||||||
|
</div>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Nests - ' + nest.name}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{nest.name}</h2>
|
||||||
|
{(nest.description || '').length < 1 ? (
|
||||||
|
<p css={tw`text-base text-neutral-400`}>
|
||||||
|
<span css={tw`italic`}>No description</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
{nest.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex flex-row ml-auto pl-4`}>
|
||||||
|
<ImportEggButton css={tw`mr-4`} />
|
||||||
|
|
||||||
|
<NavLink to={`/admin/nests/${params.nestId}/new`}>
|
||||||
|
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`}>
|
||||||
|
New Egg
|
||||||
|
</Button>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'nest'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<div css={tw`flex flex-col lg:flex-row mb-8`}>
|
||||||
|
<EditInformationContainer />
|
||||||
|
<ViewDetailsContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NestEggTable />
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<Context.Provider>
|
||||||
|
<NestEditContainer />
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
160
resources/scripts/components/admin/nests/NestEggTable.tsx
Normal file
160
resources/scripts/components/admin/nests/NestEggTable.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { NavLink, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { Filters } from '@/api/admin/nests/getEggs';
|
||||||
|
import getEggs, { Context as EggsContext } from '@/api/admin/nests/getEggs';
|
||||||
|
import AdminCheckbox from '@/components/admin/AdminCheckbox';
|
||||||
|
import AdminTable, {
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Pagination,
|
||||||
|
Loading,
|
||||||
|
NoItems,
|
||||||
|
ContentWrapper,
|
||||||
|
useTableHooks,
|
||||||
|
} from '@/components/admin/AdminTable';
|
||||||
|
import { Context } from '@/components/admin/nests/NestEditContainer';
|
||||||
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
|
const RowCheckbox = ({ id }: { id: number }) => {
|
||||||
|
const isChecked = Context.useStoreState(state => state.selectedEggs.indexOf(id) >= 0);
|
||||||
|
const appendSelectedEggs = Context.useStoreActions(actions => actions.appendSelectedEggs);
|
||||||
|
const removeSelectedEggs = Context.useStoreActions(actions => actions.removeSelectedEggs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminCheckbox
|
||||||
|
name={id.toString()}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
appendSelectedEggs(id);
|
||||||
|
} else {
|
||||||
|
removeSelectedEggs(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EggsTable = () => {
|
||||||
|
const params = useParams<'nestId' | 'id'>();
|
||||||
|
|
||||||
|
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(EggsContext);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { data: eggs, error, isValidating } = getEggs(Number(params.nestId));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
clearFlashes('nests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndAddHttpError({ key: 'nests', error });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const length = eggs?.items?.length || 0;
|
||||||
|
|
||||||
|
const setSelectedEggs = Context.useStoreActions(actions => actions.setSelectedEggs);
|
||||||
|
const selectedEggsLength = Context.useStoreState(state => state.selectedEggs.length);
|
||||||
|
|
||||||
|
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedEggs(e.currentTarget.checked ? eggs?.items?.map(nest => nest.id) || [] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setFilters(null);
|
||||||
|
} else {
|
||||||
|
setFilters({ name: query });
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedEggs([]);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminTable>
|
||||||
|
<ContentWrapper
|
||||||
|
checked={selectedEggsLength === (length === 0 ? -1 : length)}
|
||||||
|
onSelectAllClick={onSelectAllClick}
|
||||||
|
onSearch={onSearch}
|
||||||
|
>
|
||||||
|
<Pagination data={eggs} onPageSelect={setPage}>
|
||||||
|
<div css={tw`overflow-x-auto`}>
|
||||||
|
<table css={tw`w-full table-auto`}>
|
||||||
|
<TableHead>
|
||||||
|
<TableHeader
|
||||||
|
name={'ID'}
|
||||||
|
direction={sort === 'id' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('id')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Name'}
|
||||||
|
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('name')}
|
||||||
|
/>
|
||||||
|
<TableHeader name={'Description'} />
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{eggs !== undefined &&
|
||||||
|
!error &&
|
||||||
|
!isValidating &&
|
||||||
|
length > 0 &&
|
||||||
|
eggs.items.map(egg => (
|
||||||
|
<TableRow key={egg.id}>
|
||||||
|
<td css={tw`pl-6`}>
|
||||||
|
<RowCheckbox id={egg.id} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={egg.id.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{egg.id}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<NavLink
|
||||||
|
to={`/admin/nests/${params.nestId}/eggs/${egg.id}`}
|
||||||
|
css={tw`text-primary-400 hover:text-primary-300`}
|
||||||
|
>
|
||||||
|
{egg.name}
|
||||||
|
</NavLink>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
{egg.description}
|
||||||
|
</td>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{eggs === undefined || (error && isValidating) ? <Loading /> : length < 1 ? <NoItems /> : null}
|
||||||
|
</div>
|
||||||
|
</Pagination>
|
||||||
|
</ContentWrapper>
|
||||||
|
</AdminTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const hooks = useTableHooks<Filters>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EggsContext.Provider value={hooks}>
|
||||||
|
<EggsTable />
|
||||||
|
</EggsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
182
resources/scripts/components/admin/nests/NestsContainer.tsx
Normal file
182
resources/scripts/components/admin/nests/NestsContainer.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { Filters } from '@/api/admin/nests/getNests';
|
||||||
|
import getNests, { Context as NestsContext } from '@/api/admin/nests/getNests';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import AdminCheckbox from '@/components/admin/AdminCheckbox';
|
||||||
|
import AdminTable, {
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Pagination,
|
||||||
|
Loading,
|
||||||
|
NoItems,
|
||||||
|
ContentWrapper,
|
||||||
|
useTableHooks,
|
||||||
|
} from '@/components/admin/AdminTable';
|
||||||
|
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||||
|
import NewNestButton from '@/components/admin/nests/NewNestButton';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import { AdminContext } from '@/state/admin';
|
||||||
|
|
||||||
|
const RowCheckbox = ({ id }: { id: number }) => {
|
||||||
|
const isChecked = AdminContext.useStoreState(state => state.nests.selectedNests.indexOf(id) >= 0);
|
||||||
|
const appendSelectedNest = AdminContext.useStoreActions(actions => actions.nests.appendSelectedNest);
|
||||||
|
const removeSelectedNest = AdminContext.useStoreActions(actions => actions.nests.removeSelectedNest);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminCheckbox
|
||||||
|
name={id.toString()}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
appendSelectedNest(id);
|
||||||
|
} else {
|
||||||
|
removeSelectedNest(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NestsContainer = () => {
|
||||||
|
const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(NestsContext);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { data: nests, error, isValidating } = getNests();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
clearFlashes('nests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAndAddHttpError({ key: 'nests', error });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const length = nests?.items?.length || 0;
|
||||||
|
|
||||||
|
const setSelectedNests = AdminContext.useStoreActions(actions => actions.nests.setSelectedNests);
|
||||||
|
const selectedNestsLength = AdminContext.useStoreState(state => state.nests.selectedNests.length);
|
||||||
|
|
||||||
|
const onSelectAllClick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedNests(e.currentTarget.checked ? nests?.items?.map(nest => nest.id) || [] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setFilters(null);
|
||||||
|
} else {
|
||||||
|
setFilters({ name: query });
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedNests([]);
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Nests'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Nests</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
All nests currently available on this system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex ml-auto pl-4`}>
|
||||||
|
<NewNestButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'nests'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<AdminTable>
|
||||||
|
<ContentWrapper
|
||||||
|
checked={selectedNestsLength === (length === 0 ? -1 : length)}
|
||||||
|
onSelectAllClick={onSelectAllClick}
|
||||||
|
onSearch={onSearch}
|
||||||
|
>
|
||||||
|
<Pagination data={nests} onPageSelect={setPage}>
|
||||||
|
<div css={tw`overflow-x-auto`}>
|
||||||
|
<table css={tw`w-full table-auto`}>
|
||||||
|
<TableHead>
|
||||||
|
<TableHeader
|
||||||
|
name={'ID'}
|
||||||
|
direction={sort === 'id' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('id')}
|
||||||
|
/>
|
||||||
|
<TableHeader
|
||||||
|
name={'Name'}
|
||||||
|
direction={sort === 'name' ? (sortDirection ? 1 : 2) : null}
|
||||||
|
onClick={() => setSort('name')}
|
||||||
|
/>
|
||||||
|
<TableHeader name={'Description'} />
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{nests !== undefined &&
|
||||||
|
!error &&
|
||||||
|
!isValidating &&
|
||||||
|
length > 0 &&
|
||||||
|
nests.items.map(nest => (
|
||||||
|
<TableRow key={nest.id}>
|
||||||
|
<td css={tw`pl-6`}>
|
||||||
|
<RowCheckbox id={nest.id} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<CopyOnClick text={nest.id.toString()}>
|
||||||
|
<code css={tw`font-mono bg-neutral-900 rounded py-1 px-2`}>
|
||||||
|
{nest.id}
|
||||||
|
</code>
|
||||||
|
</CopyOnClick>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
<NavLink
|
||||||
|
to={`/admin/nests/${nest.id}`}
|
||||||
|
css={tw`text-primary-400 hover:text-primary-300`}
|
||||||
|
>
|
||||||
|
{nest.name}
|
||||||
|
</NavLink>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
|
||||||
|
{nest.description}
|
||||||
|
</td>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{nests === undefined || (error && isValidating) ? (
|
||||||
|
<Loading />
|
||||||
|
) : length < 1 ? (
|
||||||
|
<NoItems />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Pagination>
|
||||||
|
</ContentWrapper>
|
||||||
|
</AdminTable>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const hooks = useTableHooks<Filters>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NestsContext.Provider value={hooks}>
|
||||||
|
<NestsContainer />
|
||||||
|
</NestsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
115
resources/scripts/components/admin/nests/NewEggContainer.tsx
Normal file
115
resources/scripts/components/admin/nests/NewEggContainer.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object } from 'yup';
|
||||||
|
|
||||||
|
import createEgg from '@/api/admin/eggs/createEgg';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import {
|
||||||
|
EggImageContainer,
|
||||||
|
EggInformationContainer,
|
||||||
|
EggLifecycleContainer,
|
||||||
|
EggProcessContainer,
|
||||||
|
EggProcessContainerRef,
|
||||||
|
EggStartupContainer,
|
||||||
|
} from '@/components/admin/nests/eggs/EggSettingsContainer';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
startup: string;
|
||||||
|
dockerImages: string;
|
||||||
|
configStop: string;
|
||||||
|
configStartup: string;
|
||||||
|
configFiles: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const params = useParams<{ nestId: string }>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
|
const ref = useRef<EggProcessContainerRef>();
|
||||||
|
|
||||||
|
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('egg:create');
|
||||||
|
|
||||||
|
const nestId = Number(params.nestId);
|
||||||
|
|
||||||
|
values.configStartup = (await ref.current?.getStartupConfiguration()) || '';
|
||||||
|
values.configFiles = (await ref.current?.getFilesConfiguration()) || '';
|
||||||
|
|
||||||
|
createEgg({ ...values, dockerImages: values.dockerImages.split('\n'), nestId })
|
||||||
|
.then(egg => navigate(`/admin/nests/${nestId}/eggs/${egg.id}`))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'egg:create', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'New Egg'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Egg</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
Add a new egg to the panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender key={'egg:create'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
startup: '',
|
||||||
|
dockerImages: '',
|
||||||
|
configStop: '',
|
||||||
|
configStartup: '{}',
|
||||||
|
configFiles: '{}',
|
||||||
|
}}
|
||||||
|
validationSchema={object().shape({})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<Form>
|
||||||
|
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
|
||||||
|
<EggInformationContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EggStartupContainer css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
|
||||||
|
<EggImageContainer />
|
||||||
|
<EggLifecycleContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EggProcessContainer ref={ref} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-6 mb-16`}>
|
||||||
|
<div css={tw`flex flex-row`}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="small"
|
||||||
|
css={tw`ml-auto`}
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
112
resources/scripts/components/admin/nests/NewNestButton.tsx
Normal file
112
resources/scripts/components/admin/nests/NewNestButton.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import createNest from '@/api/admin/nests/createNest';
|
||||||
|
import getNests from '@/api/admin/nests/getNests';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import Modal from '@/components/elements/Modal';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = object().shape({
|
||||||
|
name: string()
|
||||||
|
.required('A nest name must be provided.')
|
||||||
|
.max(32, 'Nest name must not exceed 32 characters.'),
|
||||||
|
description: string()
|
||||||
|
.max(255, 'Nest description must not exceed 255 characters.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { mutate } = getNests();
|
||||||
|
|
||||||
|
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('nest:create');
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
createNest(name, description)
|
||||||
|
.then(async (nest) => {
|
||||||
|
await mutate(data => ({ ...data!, items: data!.items.concat(nest) }), false);
|
||||||
|
setVisible(false);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearAndAddHttpError({ key: 'nest:create', error });
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{ name: '', description: '' }}
|
||||||
|
validationSchema={schema}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
({ isSubmitting, resetForm }) => (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
dismissable={!isSubmitting}
|
||||||
|
showSpinnerOverlay={isSubmitting}
|
||||||
|
onDismissed={() => {
|
||||||
|
resetForm();
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlashMessageRender byKey={'nest:create'} css={tw`mb-6`}/>
|
||||||
|
|
||||||
|
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Nest</h2>
|
||||||
|
|
||||||
|
<Form css={tw`m-0`}>
|
||||||
|
<Field
|
||||||
|
type={'text'}
|
||||||
|
id={'name'}
|
||||||
|
name={'name'}
|
||||||
|
label={'Name'}
|
||||||
|
description={'A short name used to identify this nest.'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div css={tw`mt-6`}>
|
||||||
|
<Field
|
||||||
|
type={'text'}
|
||||||
|
id={'description'}
|
||||||
|
name={'description'}
|
||||||
|
label={'Description'}
|
||||||
|
description={'A description for this nest.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex flex-wrap justify-end mt-6`}>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
isSecondary
|
||||||
|
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
|
||||||
|
Create Nest
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
<Button type={'button'} size={'large'} css={tw`h-10 px-4 py-0 whitespace-nowrap`} onClick={() => setVisible(true)}>
|
||||||
|
New Nest
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,73 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import deleteEgg from '@/api/admin/eggs/deleteEgg';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eggId: number;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ eggId, onDeleted }: Props) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
setLoading(true);
|
||||||
|
clearFlashes('egg');
|
||||||
|
|
||||||
|
deleteEgg(eggId)
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'egg', error });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={'Delete egg?'}
|
||||||
|
buttonText={'Yes, delete egg'}
|
||||||
|
onConfirmed={onDelete}
|
||||||
|
showSpinnerOverlay={loading}
|
||||||
|
onModalDismissed={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this egg? You may only delete an egg with no servers using it.
|
||||||
|
</ConfirmationModal>
|
||||||
|
|
||||||
|
<Button type={'button'} size={'xsmall'} color={'red'} onClick={() => setVisible(true)}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
css={tw`h-5 w-5`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,85 @@
|
|||||||
|
import { exportEgg } from '@/api/admin/egg';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
// import { jsonLanguage } from '@codemirror/lang-json';
|
||||||
|
// import Editor from '@/components/elements/Editor';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Modal from '@/components/elements/Modal';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
export default ({ className }: { className?: string }) => {
|
||||||
|
const params = useParams<'id'>();
|
||||||
|
const { clearAndAddHttpError, clearFlashes } = useFlash();
|
||||||
|
|
||||||
|
const [visible, setVisible] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [_content, setContent] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFlashes('egg:export');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
exportEgg(Number(params.id))
|
||||||
|
.then(setContent)
|
||||||
|
.catch(error => clearAndAddHttpError({ key: 'egg:export', error }))
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
onDismissed={() => {
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
css={tw`relative`}
|
||||||
|
>
|
||||||
|
<SpinnerOverlay visible={loading} />
|
||||||
|
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>Export Egg</h2>
|
||||||
|
<FlashMessageRender byKey={'egg:export'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
{/*<Editor*/}
|
||||||
|
{/* overrides={tw`h-[32rem] rounded`}*/}
|
||||||
|
{/* initialContent={content !== null ? JSON.stringify(content, null, '\t') : ''}*/}
|
||||||
|
{/* mode={jsonLanguage}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
|
||||||
|
<div css={tw`flex flex-wrap justify-end mt-4 sm:mt-6`}>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
isSecondary
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
css={tw`w-full sm:w-auto mt-4 sm:mt-0`}
|
||||||
|
// onClick={submit}
|
||||||
|
// TODO: When clicked, save as a JSON file.
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
size={'small'}
|
||||||
|
css={tw`px-4 py-0 whitespace-nowrap`}
|
||||||
|
className={className}
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
isSecondary
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,110 @@
|
|||||||
|
import { useEggFromRoute } from '@/api/admin/egg';
|
||||||
|
import updateEgg from '@/api/admin/eggs/updateEgg';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
// import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||||
|
import { faScroll } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
// import Editor from '@/components/elements/Editor';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
scriptContainer: string;
|
||||||
|
scriptEntry: string;
|
||||||
|
scriptInstall: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EggInstallContainer() {
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
|
const { data: egg } = useEggFromRoute();
|
||||||
|
|
||||||
|
if (!egg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchFileContent: (() => Promise<string>) | null = null;
|
||||||
|
|
||||||
|
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
if (fetchFileContent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.scriptInstall = await fetchFileContent();
|
||||||
|
|
||||||
|
clearFlashes('egg');
|
||||||
|
|
||||||
|
updateEgg(egg.id, values)
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'egg', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{
|
||||||
|
scriptContainer: egg.scriptContainer,
|
||||||
|
scriptEntry: egg.scriptEntry,
|
||||||
|
scriptInstall: '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<AdminBox icon={faScroll} title={'Install Script'} noPadding>
|
||||||
|
<div css={tw`relative pb-4`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Form>
|
||||||
|
{/*<Editor*/}
|
||||||
|
{/* overrides={tw`h-96 mb-4`}*/}
|
||||||
|
{/* initialContent={egg.scriptInstall || ''}*/}
|
||||||
|
{/* mode={shell}*/}
|
||||||
|
{/* fetchContent={value => {*/}
|
||||||
|
{/* fetchFileContent = value;*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
|
||||||
|
<div css={tw`mx-6 mb-4`}>
|
||||||
|
<div css={tw`grid grid-cols-3 gap-x-8 gap-y-6`}>
|
||||||
|
<Field
|
||||||
|
id={'scriptContainer'}
|
||||||
|
name={'scriptContainer'}
|
||||||
|
label={'Install Container'}
|
||||||
|
type={'text'}
|
||||||
|
description={'The Docker image to use for running this installation script.'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id={'scriptEntry'}
|
||||||
|
name={'scriptEntry'}
|
||||||
|
label={'Install Entrypoint'}
|
||||||
|
type={'text'}
|
||||||
|
description={
|
||||||
|
'The command that should be used to run this script inside of the installation container.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex flex-row border-t border-neutral-600`}>
|
||||||
|
<Button
|
||||||
|
type={'submit'}
|
||||||
|
size={'small'}
|
||||||
|
css={tw`ml-auto mr-6 mt-4`}
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</AdminBox>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
90
resources/scripts/components/admin/nests/eggs/EggRouter.tsx
Normal file
90
resources/scripts/components/admin/nests/eggs/EggRouter.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Route, Routes, useParams } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import { useEggFromRoute } from '@/api/admin/egg';
|
||||||
|
import EggInstallContainer from '@/components/admin/nests/eggs/EggInstallContainer';
|
||||||
|
import EggVariablesContainer from '@/components/admin/nests/eggs/EggVariablesContainer';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
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 EggSettingsContainer from '@/components/admin/nests/eggs/EggSettingsContainer';
|
||||||
|
|
||||||
|
const EggRouter = () => {
|
||||||
|
const { id, nestId } = useParams<'nestId' | 'id'>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
const { data: egg, error, isValidating, mutate } = useEggFromRoute();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) clearFlashes('egg');
|
||||||
|
if (error) clearAndAddHttpError({ key: 'egg', error });
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
if (!egg || (error && isValidating)) {
|
||||||
|
return (
|
||||||
|
<AdminContentBlock showFlashKey={'egg'}>
|
||||||
|
<Spinner size={'large'} centered />
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'Egg - ' + egg.name}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-4`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>{egg.name}</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
{egg.uuid}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'egg'} css={tw`mb-4`} />
|
||||||
|
|
||||||
|
<SubNavigation>
|
||||||
|
<SubNavigationLink to={`/admin/nests/${nestId ?? ''}/eggs/${id ?? ''}`} name={'About'}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 110 2h-3a1 1 0 01-1-1v-2a1 1 0 00-1-1H9a1 1 0 00-1 1v2a1 1 0 01-1 1H4a1 1 0 110-2V4zm3 1h2v2H7V5zm2 4H7v2h2V9zm2-4h2v2h-2V5zm2 4h-2v2h2V9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</SubNavigationLink>
|
||||||
|
|
||||||
|
<SubNavigationLink to={`/admin/nests/${nestId ?? ''}/eggs/${id ?? ''}/variables`} name={'Variables'}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
</SubNavigationLink>
|
||||||
|
|
||||||
|
<SubNavigationLink to={`/admin/nests/${nestId ?? ''}/eggs/${id ?? ''}/install`} name={'Install Script'}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 01-1.414-1.414L7.586 10 5.293 7.707a1 1 0 010-1.414zM11 12a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</SubNavigationLink>
|
||||||
|
</SubNavigation>
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
<Route path="" element={<EggSettingsContainer />} />
|
||||||
|
<Route path="variables" element={<EggVariablesContainer />} />
|
||||||
|
<Route path="install" element={<EggInstallContainer />} />
|
||||||
|
</Routes>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <EggRouter />;
|
||||||
|
};
|
@ -0,0 +1,245 @@
|
|||||||
|
import { useEggFromRoute } from '@/api/admin/egg';
|
||||||
|
import updateEgg from '@/api/admin/eggs/updateEgg';
|
||||||
|
import EggDeleteButton from '@/components/admin/nests/eggs/EggDeleteButton';
|
||||||
|
import EggExportButton from '@/components/admin/nests/eggs/EggExportButton';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
// import Editor from '@/components/elements/Editor';
|
||||||
|
import Field, { TextareaField } from '@/components/elements/Field';
|
||||||
|
import Input from '@/components/elements/Input';
|
||||||
|
import Label from '@/components/elements/Label';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
// import { jsonLanguage } from '@codemirror/lang-json';
|
||||||
|
import { faDocker } from '@fortawesome/free-brands-svg-icons';
|
||||||
|
import { faEgg, faFireAlt, faMicrochip, faTerminal } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { object } from 'yup';
|
||||||
|
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
export function EggInformationContainer() {
|
||||||
|
const { isSubmitting } = useFormikContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox icon={faEgg} title={'Egg Information'} css={tw`relative`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Field id={'name'} name={'name'} label={'Name'} type={'text'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<Field id={'description'} name={'description'} label={'Description'} type={'text'} css={tw`mb-2`} />
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EggDetailsContainer() {
|
||||||
|
const { data: egg } = useEggFromRoute();
|
||||||
|
|
||||||
|
if (!egg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox icon={faEgg} title={'Egg Details'} css={tw`relative`}>
|
||||||
|
<div css={tw`mb-6`}>
|
||||||
|
<Label>UUID</Label>
|
||||||
|
<Input id={'uuid'} name={'uuid'} type={'text'} value={egg.uuid} readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`mb-2`}>
|
||||||
|
<Label>Author</Label>
|
||||||
|
<Input id={'author'} name={'author'} type={'text'} value={egg.author} readOnly />
|
||||||
|
</div>
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EggStartupContainer({ className }: { className?: string }) {
|
||||||
|
const { isSubmitting } = useFormikContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox icon={faTerminal} title={'Startup Command'} css={tw`relative`} className={className}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Field id={'startup'} name={'startup'} label={'Startup Command'} type={'text'} css={tw`mb-1`} />
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EggImageContainer() {
|
||||||
|
const { isSubmitting } = useFormikContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox icon={faDocker} title={'Docker'} css={tw`relative`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<TextareaField id={'dockerImages'} name={'dockerImages'} label={'Docker Images'} rows={5} />
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EggLifecycleContainer() {
|
||||||
|
const { isSubmitting } = useFormikContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox icon={faFireAlt} title={'Lifecycle'} css={tw`relative`}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<Field id={'configStop'} name={'configStop'} label={'Stop Command'} type={'text'} css={tw`mb-1`} />
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EggProcessContainerProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EggProcessContainerRef {
|
||||||
|
getStartupConfiguration: () => Promise<string | null>;
|
||||||
|
getFilesConfiguration: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EggProcessContainer = forwardRef<any, EggProcessContainerProps>(function EggProcessContainer(
|
||||||
|
{ className },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
// const { isSubmitting, values } = useFormikContext<Values>();
|
||||||
|
const { isSubmitting } = useFormikContext<Values>();
|
||||||
|
|
||||||
|
let fetchStartupConfiguration: (() => Promise<string>) | null = null;
|
||||||
|
let fetchFilesConfiguration: (() => Promise<string>) | null = null;
|
||||||
|
|
||||||
|
useImperativeHandle<EggProcessContainerRef, EggProcessContainerRef>(ref, () => ({
|
||||||
|
getStartupConfiguration: async () => {
|
||||||
|
if (fetchStartupConfiguration === null) {
|
||||||
|
return new Promise<null>(resolve => resolve(null));
|
||||||
|
}
|
||||||
|
return await fetchStartupConfiguration();
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilesConfiguration: async () => {
|
||||||
|
if (fetchFilesConfiguration === null) {
|
||||||
|
return new Promise<null>(resolve => resolve(null));
|
||||||
|
}
|
||||||
|
return await fetchFilesConfiguration();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox icon={faMicrochip} title={'Process Configuration'} css={tw`relative`} className={className}>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<div css={tw`mb-5`}>
|
||||||
|
<Label>Startup Configuration</Label>
|
||||||
|
{/*<Editor*/}
|
||||||
|
{/* mode={jsonLanguage}*/}
|
||||||
|
{/* initialContent={values.configStartup}*/}
|
||||||
|
{/* overrides={tw`h-32 rounded`}*/}
|
||||||
|
{/* fetchContent={value => {*/}
|
||||||
|
{/* fetchStartupConfiguration = value;*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`mb-1`}>
|
||||||
|
<Label>Configuration Files</Label>
|
||||||
|
{/*<Editor*/}
|
||||||
|
{/* mode={jsonLanguage}*/}
|
||||||
|
{/* initialContent={values.configFiles}*/}
|
||||||
|
{/* overrides={tw`h-48 rounded`}*/}
|
||||||
|
{/* fetchContent={value => {*/}
|
||||||
|
{/* fetchFilesConfiguration = value;*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
</div>
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
startup: string;
|
||||||
|
dockerImages: string;
|
||||||
|
configStop: string;
|
||||||
|
configStartup: string;
|
||||||
|
configFiles: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EggSettingsContainer() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const ref = useRef<EggProcessContainerRef>();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
|
const { data: egg } = useEggFromRoute();
|
||||||
|
|
||||||
|
if (!egg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('egg');
|
||||||
|
|
||||||
|
values.configStartup = (await ref.current?.getStartupConfiguration()) || '';
|
||||||
|
values.configFiles = (await ref.current?.getFilesConfiguration()) || '';
|
||||||
|
|
||||||
|
updateEgg(egg.id, {
|
||||||
|
...values,
|
||||||
|
// TODO
|
||||||
|
dockerImages: {},
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'egg', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{
|
||||||
|
name: egg.name,
|
||||||
|
description: egg.description || '',
|
||||||
|
startup: egg.startup,
|
||||||
|
// TODO
|
||||||
|
dockerImages: egg.dockerImages.toString(),
|
||||||
|
configStop: egg.configStop || '',
|
||||||
|
configStartup: JSON.stringify(egg.configStartup, null, '\t') || '',
|
||||||
|
configFiles: JSON.stringify(egg.configFiles, null, '\t') || '',
|
||||||
|
}}
|
||||||
|
validationSchema={object().shape({})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<Form>
|
||||||
|
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
|
||||||
|
<EggInformationContainer />
|
||||||
|
<EggDetailsContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EggStartupContainer css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
|
||||||
|
<EggImageContainer />
|
||||||
|
<EggLifecycleContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EggProcessContainer ref={ref} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<div css={tw`bg-neutral-700 rounded shadow-md px-4 xl:px-5 py-4 mb-16`}>
|
||||||
|
<div css={tw`flex flex-row`}>
|
||||||
|
<EggDeleteButton eggId={egg.id} onDeleted={() => navigate('/admin/nests')} />
|
||||||
|
<EggExportButton css={tw`ml-auto mr-4`} />
|
||||||
|
<Button type="submit" size="small" disabled={isSubmitting || !isValid}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,218 @@
|
|||||||
|
import { TrashIcon } from '@heroicons/react/outline';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik, useFormikContext } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { array, boolean, object, string } from 'yup';
|
||||||
|
|
||||||
|
import deleteEggVariable from '@/api/admin/eggs/deleteEggVariable';
|
||||||
|
import updateEggVariables from '@/api/admin/eggs/updateEggVariables';
|
||||||
|
import { NoItems } from '@/components/admin/AdminTable';
|
||||||
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
|
import type { EggVariable } from '@/api/admin/egg';
|
||||||
|
import { useEggFromRoute } from '@/api/admin/egg';
|
||||||
|
import NewVariableButton from '@/components/admin/nests/eggs/NewVariableButton';
|
||||||
|
import AdminBox from '@/components/admin/AdminBox';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import Checkbox from '@/components/elements/Checkbox';
|
||||||
|
import Field, { FieldRow, TextareaField } from '@/components/elements/Field';
|
||||||
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
|
export const validationSchema = object().shape({
|
||||||
|
name: string().required().min(1).max(191),
|
||||||
|
description: string(),
|
||||||
|
environmentVariable: string().required().min(1).max(191),
|
||||||
|
defaultValue: string(),
|
||||||
|
isUserViewable: boolean().required(),
|
||||||
|
isUserEditable: boolean().required(),
|
||||||
|
rules: string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function EggVariableForm({ prefix }: { prefix: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field id={`${prefix}name`} name={`${prefix}name`} label={'Name'} type={'text'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
id={`${prefix}description`}
|
||||||
|
name={`${prefix}description`}
|
||||||
|
label={'Description'}
|
||||||
|
rows={3}
|
||||||
|
css={tw`mb-4`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldRow>
|
||||||
|
<Field
|
||||||
|
id={`${prefix}environmentVariable`}
|
||||||
|
name={`${prefix}environmentVariable`}
|
||||||
|
label={'Environment Variable'}
|
||||||
|
type={'text'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id={`${prefix}defaultValue`}
|
||||||
|
name={`${prefix}defaultValue`}
|
||||||
|
label={'Default Value'}
|
||||||
|
type={'text'}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
<div css={tw`flex flex-row mb-6`}>
|
||||||
|
<Checkbox id={`${prefix}isUserViewable`} name={`${prefix}isUserViewable`} label={'User Viewable'} />
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
id={`${prefix}isUserEditable`}
|
||||||
|
name={`${prefix}isUserEditable`}
|
||||||
|
label={'User Editable'}
|
||||||
|
css={tw`ml-auto`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id={`${prefix}rules`}
|
||||||
|
name={`${prefix}rules`}
|
||||||
|
label={'Validation Rules'}
|
||||||
|
type={'text'}
|
||||||
|
css={tw`mb-2`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EggVariableDeleteButton({ onClick }: { onClick: (success: () => void) => void }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
onClick(() => {
|
||||||
|
//setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={'Delete variable?'}
|
||||||
|
buttonText={'Yes, delete variable'}
|
||||||
|
onConfirmed={onDelete}
|
||||||
|
showSpinnerOverlay={loading}
|
||||||
|
onModalDismissed={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Are you sure you want to delete this variable? Deleting this variable will delete it from every server
|
||||||
|
using this egg.
|
||||||
|
</ConfirmationModal>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type={'button'}
|
||||||
|
css={tw`ml-auto text-neutral-500 hover:text-neutral-300`}
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
>
|
||||||
|
<TrashIcon css={tw`h-5 w-5`} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EggVariableBox({
|
||||||
|
onDeleteClick,
|
||||||
|
variable,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
onDeleteClick: (success: () => void) => void;
|
||||||
|
variable: EggVariable;
|
||||||
|
prefix: string;
|
||||||
|
}) {
|
||||||
|
const { isSubmitting } = useFormikContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBox
|
||||||
|
css={tw`relative w-full`}
|
||||||
|
title={<p css={tw`text-sm uppercase`}>{variable.name}</p>}
|
||||||
|
button={<EggVariableDeleteButton onClick={onDeleteClick} />}
|
||||||
|
>
|
||||||
|
<SpinnerOverlay visible={isSubmitting} />
|
||||||
|
|
||||||
|
<EggVariableForm prefix={prefix} />
|
||||||
|
</AdminBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EggVariablesContainer() {
|
||||||
|
const { clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
|
const { data: egg, mutate } = useEggFromRoute();
|
||||||
|
|
||||||
|
if (!egg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (values: EggVariable[], { setSubmitting }: FormikHelpers<EggVariable[]>) => {
|
||||||
|
updateEggVariables(egg.id, values)
|
||||||
|
.then(async () => await mutate())
|
||||||
|
.catch(error => clearAndAddHttpError({ key: 'egg', error }))
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={egg.relationships.variables}
|
||||||
|
validationSchema={array().of(validationSchema)}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<Form>
|
||||||
|
<div css={tw`flex flex-col mb-16`}>
|
||||||
|
{egg.relationships.variables?.length === 0 ? (
|
||||||
|
<NoItems css={tw`bg-neutral-700 rounded-md shadow-md`} />
|
||||||
|
) : (
|
||||||
|
<div css={tw`grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-x-8 gap-y-6`}>
|
||||||
|
{egg.relationships.variables.map((v, i) => (
|
||||||
|
<EggVariableBox
|
||||||
|
key={i}
|
||||||
|
prefix={`[${i}].`}
|
||||||
|
variable={v}
|
||||||
|
onDeleteClick={success => {
|
||||||
|
deleteEggVariable(egg.id, v.id)
|
||||||
|
.then(async () => {
|
||||||
|
await mutate(egg => ({
|
||||||
|
...egg!,
|
||||||
|
relationships: {
|
||||||
|
...egg!.relationships,
|
||||||
|
variables: egg!.relationships.variables!.filter(
|
||||||
|
v2 => v.id === v2.id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
success();
|
||||||
|
})
|
||||||
|
.catch(error => clearAndAddHttpError({ key: 'egg', error }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div css={tw`bg-neutral-700 rounded shadow-md py-2 px-4 mt-6`}>
|
||||||
|
<div css={tw`flex flex-row`}>
|
||||||
|
<NewVariableButton />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={'submit'}
|
||||||
|
size={'small'}
|
||||||
|
css={tw`ml-auto`}
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik, useFormikContext } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
import type { CreateEggVariable } from '@/api/admin/eggs/createEggVariable';
|
||||||
|
import createEggVariable from '@/api/admin/eggs/createEggVariable';
|
||||||
|
import { useEggFromRoute } from '@/api/admin/egg';
|
||||||
|
import { EggVariableForm, validationSchema } from '@/components/admin/nests/eggs/EggVariablesContainer';
|
||||||
|
import Modal from '@/components/elements/Modal';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
|
export default function NewVariableButton() {
|
||||||
|
const { setValues } = useFormikContext();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
|
|
||||||
|
const { data: egg, mutate } = useEggFromRoute();
|
||||||
|
|
||||||
|
if (!egg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (values: CreateEggVariable, { setSubmitting }: FormikHelpers<CreateEggVariable>) => {
|
||||||
|
clearFlashes('variable:create');
|
||||||
|
|
||||||
|
createEggVariable(egg.id, values)
|
||||||
|
.then(async variable => {
|
||||||
|
setValues([...egg.relationships.variables, variable]);
|
||||||
|
await mutate(egg => ({
|
||||||
|
...egg!,
|
||||||
|
relationships: { ...egg!.relationships, variables: [...egg!.relationships.variables, variable] },
|
||||||
|
}));
|
||||||
|
setVisible(false);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
clearAndAddHttpError({ key: 'variable:create', error });
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
environmentVariable: '',
|
||||||
|
defaultValue: '',
|
||||||
|
isUserViewable: false,
|
||||||
|
isUserEditable: false,
|
||||||
|
rules: '',
|
||||||
|
}}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid, resetForm }) => (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
dismissable={!isSubmitting}
|
||||||
|
showSpinnerOverlay={isSubmitting}
|
||||||
|
onDismissed={() => {
|
||||||
|
resetForm();
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlashMessageRender byKey={'variable:create'} css={tw`mb-6`} />
|
||||||
|
|
||||||
|
<h2 css={tw`mb-6 text-2xl text-neutral-100`}>New Variable</h2>
|
||||||
|
|
||||||
|
<Form css={tw`m-0`}>
|
||||||
|
<EggVariableForm prefix={''} />
|
||||||
|
|
||||||
|
<div css={tw`flex flex-wrap justify-end mt-6`}>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
isSecondary
|
||||||
|
css={tw`w-full sm:w-auto sm:mr-2`}
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
css={tw`w-full mt-4 sm:w-auto sm:mt-0`}
|
||||||
|
type={'submit'}
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
Create Variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
|
||||||
|
<Button type={'button'} color={'green'} onClick={() => setVisible(true)}>
|
||||||
|
New Variable
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
56
resources/scripts/components/admin/nodes/DatabaseSelect.tsx
Normal file
56
resources/scripts/components/admin/nodes/DatabaseSelect.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { Database } from '@/api/admin/databases/getDatabases';
|
||||||
|
import searchDatabases from '@/api/admin/databases/searchDatabases';
|
||||||
|
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
|
||||||
|
|
||||||
|
export default ({ selected }: { selected: Database | null }) => {
|
||||||
|
const context = useFormikContext();
|
||||||
|
|
||||||
|
const [database, setDatabase] = useState<Database | null>(selected);
|
||||||
|
const [databases, setDatabases] = useState<Database[] | null>(null);
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
searchDatabases({ name: query })
|
||||||
|
.then(databases => {
|
||||||
|
setDatabases(databases);
|
||||||
|
return resolve();
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (database: Database | null) => {
|
||||||
|
setDatabase(database);
|
||||||
|
context.setFieldValue('databaseHostId', database?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedText = (database: Database | null): string | undefined => {
|
||||||
|
return database?.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
id={'databaseId'}
|
||||||
|
name={'databaseId'}
|
||||||
|
label={'Database Host'}
|
||||||
|
placeholder={'Select a database host...'}
|
||||||
|
items={databases}
|
||||||
|
selected={database}
|
||||||
|
setSelected={setDatabase}
|
||||||
|
setItems={setDatabases}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onSelect={onSelect}
|
||||||
|
getSelectedText={getSelectedText}
|
||||||
|
nullable
|
||||||
|
>
|
||||||
|
{databases?.map(d => (
|
||||||
|
<Option key={d.id} selectId={'databaseId'} id={d.id} item={d} active={d.id === database?.id}>
|
||||||
|
{d.name}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</SearchableSelect>
|
||||||
|
);
|
||||||
|
};
|
56
resources/scripts/components/admin/nodes/LocationSelect.tsx
Normal file
56
resources/scripts/components/admin/nodes/LocationSelect.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { Location } from '@/api/admin/locations/getLocations';
|
||||||
|
import searchLocations from '@/api/admin/locations/searchLocations';
|
||||||
|
import SearchableSelect, { Option } from '@/components/elements/SearchableSelect';
|
||||||
|
|
||||||
|
export default ({ selected }: { selected: Location | null }) => {
|
||||||
|
const context = useFormikContext();
|
||||||
|
|
||||||
|
const [location, setLocation] = useState<Location | null>(selected);
|
||||||
|
const [locations, setLocations] = useState<Location[] | null>(null);
|
||||||
|
|
||||||
|
const onSearch = (query: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
searchLocations({ short: query })
|
||||||
|
.then(locations => {
|
||||||
|
setLocations(locations);
|
||||||
|
return resolve();
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelect = (location: Location | null) => {
|
||||||
|
setLocation(location);
|
||||||
|
context.setFieldValue('locationId', location?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedText = (location: Location | null): string | undefined => {
|
||||||
|
return location?.short;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
id={'locationId'}
|
||||||
|
name={'locationId'}
|
||||||
|
label={'Location'}
|
||||||
|
placeholder={'Select a location...'}
|
||||||
|
items={locations}
|
||||||
|
selected={location}
|
||||||
|
setSelected={setLocation}
|
||||||
|
setItems={setLocations}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onSelect={onSelect}
|
||||||
|
getSelectedText={getSelectedText}
|
||||||
|
nullable
|
||||||
|
>
|
||||||
|
{locations?.map(d => (
|
||||||
|
<Option key={d.id} selectId={'locationId'} id={d.id} item={d} active={d.id === location?.id}>
|
||||||
|
{d.short}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</SearchableSelect>
|
||||||
|
);
|
||||||
|
};
|
127
resources/scripts/components/admin/nodes/NewNodeContainer.tsx
Normal file
127
resources/scripts/components/admin/nodes/NewNodeContainer.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import type { Actions } from 'easy-peasy';
|
||||||
|
import { useStoreActions } from 'easy-peasy';
|
||||||
|
import type { FormikHelpers } from 'formik';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { number, object, string } from 'yup';
|
||||||
|
|
||||||
|
import type { Values } from '@/api/admin/nodes/createNode';
|
||||||
|
import createNode from '@/api/admin/nodes/createNode';
|
||||||
|
import AdminContentBlock from '@/components/admin/AdminContentBlock';
|
||||||
|
import NodeLimitContainer from '@/components/admin/nodes/NodeLimitContainer';
|
||||||
|
import NodeListenContainer from '@/components/admin/nodes/NodeListenContainer';
|
||||||
|
import NodeSettingsContainer from '@/components/admin/nodes/NodeSettingsContainer';
|
||||||
|
import Button from '@/components/elements/Button';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import type { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
type Values2 = Omit<Omit<Values, 'behindProxy'>, 'public'> & { behindProxy: string; public: string };
|
||||||
|
|
||||||
|
const initialValues: Values2 = {
|
||||||
|
name: '',
|
||||||
|
locationId: 0,
|
||||||
|
databaseHostId: null,
|
||||||
|
fqdn: '',
|
||||||
|
scheme: 'https',
|
||||||
|
behindProxy: 'false',
|
||||||
|
public: 'true',
|
||||||
|
daemonBase: '/var/lib/pterodactyl/volumes',
|
||||||
|
|
||||||
|
listenPortHTTP: 8080,
|
||||||
|
publicPortHTTP: 8080,
|
||||||
|
listenPortSFTP: 2022,
|
||||||
|
publicPortSFTP: 2022,
|
||||||
|
|
||||||
|
memory: 0,
|
||||||
|
memoryOverallocate: 0,
|
||||||
|
disk: 0,
|
||||||
|
diskOverallocate: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
|
||||||
|
(actions: Actions<ApplicationStore>) => actions.flashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = (values2: Values2, { setSubmitting }: FormikHelpers<Values2>) => {
|
||||||
|
clearFlashes('node:create');
|
||||||
|
|
||||||
|
const values: Values = {
|
||||||
|
...values2,
|
||||||
|
behindProxy: values2.behindProxy === 'true',
|
||||||
|
public: values2.public === 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
createNode(values)
|
||||||
|
.then(node => navigate(`/admin/nodes/${node.id}`))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
clearAndAddHttpError({ key: 'node:create', error });
|
||||||
|
})
|
||||||
|
.then(() => setSubmitting(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminContentBlock title={'New Node'}>
|
||||||
|
<div css={tw`w-full flex flex-row items-center mb-8`}>
|
||||||
|
<div css={tw`flex flex-col flex-shrink`} style={{ minWidth: '0' }}>
|
||||||
|
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>New Node</h2>
|
||||||
|
<p css={tw`text-base text-neutral-400 whitespace-nowrap overflow-ellipsis overflow-hidden`}>
|
||||||
|
Add a new node to the panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlashMessageRender byKey={'node:create'} />
|
||||||
|
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
name: string().required().max(191),
|
||||||
|
|
||||||
|
listenPortHTTP: number().required(),
|
||||||
|
publicPortHTTP: number().required(),
|
||||||
|
listenPortSFTP: number().required(),
|
||||||
|
publicPortSFTP: number().required(),
|
||||||
|
|
||||||
|
memory: number().required(),
|
||||||
|
memoryOverallocate: number().required(),
|
||||||
|
disk: number().required(),
|
||||||
|
diskOverallocate: number().required(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<Form>
|
||||||
|
<div css={tw`flex flex-col lg:flex-row`}>
|
||||||
|
<div css={tw`w-full lg:w-1/2 flex flex-col mr-0 lg:mr-2`}>
|
||||||
|
<NodeSettingsContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`w-full lg:w-1/2 flex flex-col ml-0 lg:ml-2 mt-4 lg:mt-0`}>
|
||||||
|
<div css={tw`flex w-full`}>
|
||||||
|
<NodeListenContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`flex w-full mt-4`}>
|
||||||
|
<NodeLimitContainer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={tw`rounded shadow-md bg-neutral-700 mt-4 py-2 pr-6`}>
|
||||||
|
<div css={tw`flex flex-row`}>
|
||||||
|
<Button type={'submit'} css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</AdminContentBlock>
|
||||||
|
);
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user