feat: a controversial commit

This commit is contained in:
Elizabeth
2025-08-08 07:18:03 -05:00
parent aa2897e395
commit 4ec067a71b
25 changed files with 1139 additions and 518 deletions

View File

@@ -5,6 +5,10 @@ mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php
&& chmod 777 /var/log/panel/logs/ \
&& ln -s /app/storage/logs/ /var/log/panel/
# Ensure proper permissions for Laravel storage directories
mkdir -p /app/storage/logs /app/storage/framework/cache /app/storage/framework/sessions /app/storage/framework/views \
&& chmod -R 777 /app/storage/
# Check that user has mounted the /app/var directory
if [ ! -d /app/var ]; then
echo "You must mount the /app/var directory to the container."
@@ -101,76 +105,8 @@ else
echo -e "Skipping database seeding (SKIP_SEED=True)"
fi
# Setup development environment if specified
(
source /app/.env
if [ "$PYRODACTYL_DOCKER_DEV" = "true" ] && [ "$DEV_SETUP" != "true" ]; then
echo -e "\e[42mDevelopment environment detected, setting up development resources...\e[0m"
# Create a developer user
php artisan p:user:make -n --email dev@pyro.host --username dev --name-first Developer --name-last User --password password
mariadb -u root -h database -p"$DB_ROOT_PASSWORD" --ssl=0 -e "USE panel; UPDATE users SET root_admin = 1;" # workaround because --admin is broken
# Make a location and node for the panel
php artisan p:location:make -n --short local --long Local
php artisan p:node:make -n --name local --description "Development Node" --locationId 1 --fqdn localhost --internal-fqdn $WINGS_INTERNAL_IP --public 1 --scheme http --proxy 0 --maxMemory 1024 --maxDisk 10240 --overallocateMemory 0 --overallocateDisk 0
echo "Adding dummy allocations..."
mariadb -u root -h database -p"$DB_ROOT_PASSWORD" --ssl=0 -e "USE panel; INSERT INTO allocations (node_id, ip, port) VALUES (1, '0.0.0.0', 25565), (1, '0.0.0.0', 25566), (1, '0.0.0.0', 25567);"
echo "Creating database user..."
mariadb -u root -h database -p"$DB_ROOT_PASSWORD" --ssl=0 -e "CREATE USER 'pterodactyluser'@'%' IDENTIFIED BY 'somepassword'; GRANT ALL PRIVILEGES ON *.* TO 'pterodactyluser'@'%' WITH GRANT OPTION;"
# Configure node
export WINGS_CONFIG=/etc/pterodactyl/config.yml
mkdir -p $(dirname $WINGS_CONFIG)
echo "Fetching and modifying Wings configuration file..."
CONFIG=$(php artisan p:node:configuration 1)
# Allow all origins for CORS
CONFIG=$(printf "%s\nallowed_origins: ['*']" "$CONFIG")
# Update Wings configuration paths if WINGS_DIR is set
if [ -z "$WINGS_DIR" ]; then
echo "WINGS_DIR is not set, using default paths."
else
echo "Updating Wings configuration paths to '$WINGS_DIR'..."
# add system section if it doesn't exist
if ! echo "$CONFIG" | grep -q "^system:"; then
CONFIG=$(printf "%s\nsystem:" "$CONFIG")
fi
update_config() {
local key="$1"
local value="$2"
# update existing key or add new one
if echo "$CONFIG" | grep -q "^ $key:"; then
CONFIG=$(echo "$CONFIG" | sed "s|^ $key:.*| $key: $value|")
else
CONFIG=$(echo "$CONFIG" | sed "/^system:/a\\ $key: $value")
fi
}
update_config "root_directory" "$WINGS_DIR/srv/wings/"
update_config "log_directory" "$WINGS_DIR/srv/wings/logs/"
update_config "data" "$WINGS_DIR/srv/wings/volumes"
update_config "archive_directory" "$WINGS_DIR/srv/wings/archives"
update_config "backup_directory" "$WINGS_DIR/srv/wings/backups"
update_config "tmp_directory" "$WINGS_DIR/srv/wings/tmp/"
fi
echo "Saving Wings configuration file to '$WINGS_CONFIG'..."
echo "$CONFIG" > $WINGS_CONFIG
# Mark setup as complete
echo "DEV_SETUP=true" >> /app/.env
echo "Development setup complete."
elif [ "$DEV_SETUP" = "true" ]; then
echo "Skipping development setup, already completed."
fi
)
# Development setup is now handled by the DevelopmentSeeder
# Run: php artisan db:seed --class=DevelopmentSeeder
## start cronjobs for the queue
echo -e "Starting cron jobs."

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ public/assets/manifest.json
# For local development with docker
docker-compose.yml
local_docker
/srv
/var

View File

@@ -86,6 +86,7 @@ class Node extends Model
* Fields that are mass assignable.
*/
protected $fillable = [
'uuid',
'public',
'name',
'location_id',
@@ -102,6 +103,8 @@ class Node extends Model
'daemonBase',
'daemonSFTP',
'daemonListen',
'daemon_token_id',
'daemon_token',
'description',
'maintenance_mode',
];

View File

@@ -13,5 +13,10 @@ class DatabaseSeeder extends Seeder
{
$this->call(NestSeeder::class);
$this->call(EggSeeder::class);
// Run development seeder in local environment
if (app()->environment('local')) {
$this->call(DevelopmentSeeder::class);
}
}
}

View File

@@ -0,0 +1,410 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Location;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\DatabaseHost;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Egg;
use Pterodactyl\Services\Servers\ServerCreationService;
class DevelopmentSeeder extends Seeder
{
/**
* Run the database seeds for development environment.
*/
public function run()
{
// Only run in development environment
if (!app()->environment('local')) {
$this->command->info('Development seeder only runs in local environment.');
return;
}
$this->command->info('Setting up development environment...');
// Create dev user
$user = $this->createDevUser();
$this->command->info('✓ Created dev user (username: dev, password: dev)');
// Create location
$location = $this->createLocation();
$this->command->info('✓ Created development location');
// Create Wings node
$node = $this->createNode($location);
$this->command->info('✓ Created Wings node');
// Create allocations for the node
$this->createAllocations($node);
$this->command->info('✓ Created allocations for Wings node');
// Create database host
$databaseHost = $this->createDatabaseHost($node);
$this->command->info('✓ Created database host');
// Generate Wings configuration
$this->generateWingsConfiguration($node);
$this->command->info('✓ Generated Wings configuration');
// Create testing Minecraft server
$server = $this->createTestingMinecraftServer($user, $node);
$this->command->info('✓ Created testing Minecraft server');
$this->command->info('Development environment setup complete!');
$this->command->info('Panel URL: http://localhost:3000');
$this->command->info('Username: dev');
$this->command->info('Password: dev');
if ($server) {
$this->command->info('Testing Server: ' . $server->name . ' (UUID: ' . $server->uuidShort . ')');
}
}
private function createDevUser(): User
{
// First try to find existing user by email or username
$user = User::where('email', 'dev@pyrodactyl.local')
->orWhere('username', 'dev')
->first();
if ($user) {
// Update existing user
$user->update([
'username' => 'dev',
'email' => 'dev@pyrodactyl.local',
'password' => Hash::make('dev'),
'root_admin' => true,
'language' => 'en',
'use_totp' => false,
]);
return $user;
}
// Create new user
return User::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'username' => 'dev',
'email' => 'dev@pyrodactyl.local',
'name_first' => 'Development',
'name_last' => 'User',
'password' => Hash::make('dev'),
'root_admin' => true,
'language' => 'en',
'use_totp' => false,
]);
}
private function createLocation(): Location
{
return Location::updateOrCreate(
['short' => 'dev'],
[
'long' => 'Development Location',
]
);
}
private function createNode(Location $location): Node
{
// Generate proper daemon token (64 characters as required by Node model)
$daemonTokenId = 'dev_token_id_16c'; // 16 characters as required
$daemonToken = 'dev_token_64_chars_fixed_for_development_environment_testing'; // 64 characters
// Check if node already exists
$existingNode = Node::where('name', 'Development Node')->first();
if ($existingNode) {
// Update existing node with proper values including UUID if missing
$updateData = [
'location_id' => $location->id,
'fqdn' => 'localhost', // Public FQDN for browser websocket connections
'internal_fqdn' => 'wings', // Internal FQDN for panel-to-Wings communication
'scheme' => 'http',
'behind_proxy' => false,
'public' => true,
'maintenance_mode' => false,
'memory' => 8192,
'memory_overallocate' => 0,
'disk' => 102400,
'disk_overallocate' => 0,
'upload_size' => 100,
'daemonListen' => 8080,
'daemonSFTP' => 2022,
'daemonBase' => '/var/lib/pterodactyl/volumes',
'daemon_token_id' => $daemonTokenId,
'daemon_token' => encrypt($daemonToken),
'description' => 'Development Wings node for local testing',
];
// Set UUID if it's missing
if (empty($existingNode->uuid)) {
$updateData['uuid'] = (string) \Illuminate\Support\Str::uuid();
$this->command->info("Setting UUID for existing node: {$updateData['uuid']}");
}
$existingNode->update($updateData);
return $existingNode;
}
// Create new node with UUID
$nodeUuid = (string) \Illuminate\Support\Str::uuid();
$node = Node::create([
'uuid' => $nodeUuid,
'name' => 'Development Node',
'location_id' => $location->id,
'fqdn' => 'localhost', // Public FQDN for browser websocket connections
'internal_fqdn' => 'wings', // Internal FQDN for panel-to-Wings communication
'scheme' => 'http',
'behind_proxy' => false,
'public' => true,
'maintenance_mode' => false,
'memory' => 8192,
'memory_overallocate' => 0,
'disk' => 102400,
'disk_overallocate' => 0,
'upload_size' => 100,
'daemonListen' => 8080,
'daemonSFTP' => 2022,
'daemonBase' => '/var/lib/pterodactyl/volumes',
'daemon_token_id' => $daemonTokenId,
'daemon_token' => encrypt($daemonToken),
'description' => 'Development Wings node for local testing',
]);
$this->command->info("Created node with UUID: {$nodeUuid}");
return $node;
}
private function createAllocations(Node $node): void
{
// Create a range of allocations for testing
$ports = [25565, 25566, 25567, 25568, 25569, 8080, 8081, 8082, 8083, 8084];
foreach ($ports as $port) {
Allocation::updateOrCreate(
[
'node_id' => $node->id,
'ip' => '0.0.0.0',
'port' => $port,
],
[
'ip_alias' => null,
'server_id' => null,
'notes' => 'Development allocation',
]
);
}
}
private function createDatabaseHost(Node $node): DatabaseHost
{
// For development, use root user with full privileges
$rootPassword = env('DB_ROOT_PASSWORD', 'rootpassword');
return DatabaseHost::updateOrCreate(
['name' => 'Development Database'],
[
'host' => 'database',
'port' => 3306,
'username' => 'root',
'password' => encrypt($rootPassword),
'max_databases' => 100,
'node_id' => $node->id, // Link to development node
]
);
}
private function generateWingsConfiguration(Node $node): void
{
try {
$configPath = '/etc/pterodactyl/config.yml';
// Ensure the directory exists
$configDir = dirname($configPath);
if (!File::exists($configDir)) {
File::makeDirectory($configDir, 0755, true);
}
// Generate the Wings configuration using the node's built-in method
$this->command->info('Generating Wings configuration...');
// Get the configuration directly from the node model
$configContent = $node->getYamlConfiguration();
// Add CORS configuration for development
$configContent .= "\nallowed_origins: ['*']\n";
// Update paths for Docker development environment if WINGS_DIR is set
$wingsDir = env('WINGS_DIR');
if ($wingsDir) {
$this->command->info("Updating Wings paths for Docker development environment: {$wingsDir}");
// Add system configuration section with proper Docker paths
$systemConfig = "\nsystem:\n";
$systemConfig .= " root_directory: {$wingsDir}/wings/\n";
$systemConfig .= " log_directory: {$wingsDir}/wings/logs/\n";
$systemConfig .= " data: {$wingsDir}/wings/volumes\n";
$systemConfig .= " archive_directory: {$wingsDir}/wings/archives\n";
$systemConfig .= " backup_directory: {$wingsDir}/wings/backups\n";
$systemConfig .= " tmp_directory: {$wingsDir}/wings/tmp/\n";
$systemConfig .= " sftp:\n";
$systemConfig .= " bind_port: {$node->daemonSFTP}\n";
$configContent .= $systemConfig;
}
// Write the configuration file
File::put($configPath, $configContent);
$this->command->info("Wings configuration saved to: {$configPath}");
// Also save a backup copy for debugging
$backupPath = storage_path('logs/wings-config-backup.yml');
File::put($backupPath, $configContent);
$this->command->info("Backup configuration saved to: {$backupPath}");
} catch (\Exception $e) {
$this->command->error("Failed to generate Wings configuration: " . $e->getMessage());
$this->command->info("You may need to manually configure Wings after setup.");
// Log the full error for debugging
Log::error('Wings configuration generation failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'node_id' => $node->id,
]);
}
}
private function createTestingMinecraftServer(User $user, Node $node): ?Server
{
// Check if testing server already exists
$existingServer = Server::where('name', 'Testing Minecraft Server')->first();
if ($existingServer) {
$this->command->info('Testing Minecraft server already exists, skipping creation.');
return $existingServer;
}
try {
// Get an available allocation
$allocation = Allocation::where('node_id', $node->id)
->whereNull('server_id')
->first();
if (!$allocation) {
$this->command->error('No available allocations for testing server.');
return null;
}
// Get the Vanilla Minecraft egg - try multiple possible names
$egg = Egg::whereIn('name', ['Vanilla Minecraft', 'Minecraft', 'Paper', 'Forge'])
->first();
if (!$egg) {
$this->command->error('No suitable Minecraft egg found. Available eggs:');
$availableEggs = Egg::pluck('name')->toArray();
$this->command->info('Available: ' . implode(', ', $availableEggs));
return null;
}
$this->command->info("Using egg: {$egg->name}");
// Get the default Docker image
$dockerImages = $egg->docker_images;
if (empty($dockerImages)) {
$this->command->error('No Docker images available for the selected egg.');
return null;
}
$defaultImage = is_array($dockerImages) ? array_values($dockerImages)[0] : $dockerImages;
// Prepare server creation data with specified requirements
$serverData = [
'name' => 'Testing Minecraft Server',
'description' => 'Development testing server with 4 cores, 4GB RAM, 32GB storage',
'owner_id' => $user->id,
'memory' => 4096, // 4GB RAM
'overhead_memory' => 2048, // 2GB overhead memory
'swap' => 0,
'disk' => 32768, // 32GB storage (in MB)
'io' => 500,
'cpu' => 400, // 4 cores (100% per core)
'threads' => null,
'allocation_id' => $allocation->id,
'node_id' => $node->id,
'nest_id' => $egg->nest_id,
'egg_id' => $egg->id,
'startup' => $egg->startup,
'image' => $defaultImage,
'database_limit' => 8, // 8 database slots
'allocation_limit' => 8, // 8 allocation slots
'backup_limit' => 8, // 8 backup slots
'skip_scripts' => false,
'oom_disabled' => true,
'start_on_completion' => false, // Don't auto-start in development to avoid issues
'environment' => [
'SERVER_JARFILE' => 'server.jar',
'VANILLA_VERSION' => 'latest',
],
];
// Use the proper ServerCreationService to create and provision the server
$serverCreationService = app(ServerCreationService::class);
$this->command->info('Creating server with ServerCreationService...');
$server = $serverCreationService->handle($serverData);
// Verify and fix allocation assignment if needed
$this->ensureAllocationAssignment($server, $allocation);
$this->command->info("Server created successfully with ID: {$server->id}");
return $server;
} catch (\Exception $e) {
$this->command->error("Failed to create testing Minecraft server: " . $e->getMessage());
Log::error('Server creation failed in development seeder', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'user_id' => $user->id,
'node_id' => $node->id,
]);
return null;
}
}
/**
* Ensure the allocation is properly assigned to the server.
* This fixes potential race conditions in the ServerCreationService.
*/
private function ensureAllocationAssignment(Server $server, Allocation $allocation): void
{
// Refresh the allocation from database to get current state
$allocation->refresh();
// If allocation is not assigned to the server, fix it
if ($allocation->server_id !== $server->id) {
$this->command->info('Fixing allocation assignment...');
$allocation->server_id = $server->id;
$allocation->save();
$this->command->info("Allocation {$allocation->ip}:{$allocation->port} assigned to server {$server->id}");
}
// Verify the server can see its allocations
$serverAllocations = $server->allocations()->count();
if ($serverAllocations === 0) {
$this->command->warn('Server has no allocations after creation, this may cause issues.');
} else {
$this->command->info("Server has {$serverAllocations} allocation(s) properly assigned.");
}
}
}

View File

@@ -9,7 +9,7 @@ x-common:
# A list of valid timezones can be found here: http://php.net/manual/en/timezones.php
APP_TIMEZONE: 'UTC'
APP_SERVICE_AUTHOR: 'noreply@example.com'
DB_USERNAME: 'pterodactyl'
DB_USERNAME: 'root'
# DB_PASSWORD: "Uncomment this to user your own password"
# Uncomment the line below and set to a non-empty value if you want to use Let's Encrypt
@@ -38,7 +38,7 @@ services:
restart: always
command: --default-authentication-plugin=mysql_native_password
volumes:
- './srv/database:/var/lib/mysql'
- './local_docker/database:/var/lib/mysql'
environment:
<<: *db-environment
MYSQL_DATABASE: 'panel'
@@ -47,7 +47,11 @@ services:
image: redis:alpine
restart: always
panel:
image: pyrodactyl:develop
build:
context: .
dockerfile: ./Dockerfile
args:
DEV: "true"
restart: always
ports:
- '3000:80'
@@ -57,20 +61,21 @@ services:
- cache
volumes:
- '.:/app'
- './srv/var:/app/var'
- './srv/nginx/:/etc/nginx/http.d/'
- './srv/certs:/etc/letsencrypt'
- './srv/logs/:/app/storage/logs'
- './srv/pterodactyl/config/:/etc/pterodactyl'
- './local_docker/var:/app/var'
- './local_docker/nginx/:/etc/nginx/http.d/'
- './local_docker/certs:/etc/letsencrypt'
- './local_docker/logs/:/app/storage/logs'
- './local_docker/pterodactyl/config/:/etc/pterodactyl'
# volumes set for the dirs below so the image's files are used instead of the host's files
- panel_vendor:/app/vendor
- panel_storage:/app/storage
- panel_bootstrap_cache:/app/bootstrap/cache
environment:
<<: [*panel-environment, *mail-environment]
DB_PASSWORD: *db-password
DB_PASSWORD: *db-root-password
DB_ROOT_PASSWORD: *db-root-password
APP_ENV: 'production'
APP_ENV: 'local'
APP_DEBUG: 'true'
APP_ENVIRONMENT_ONLY: 'false'
CACHE_DRIVER: 'redis'
SESSION_DRIVER: 'redis'
@@ -81,14 +86,14 @@ services:
DB_PORT: '3306'
HASHIDS_LENGTH: 8
WINGS_INTERNAL_IP: 'wings'
WINGS_DIR: '${PWD}'
WINGS_DIR: '${PWD}/local_docker'
PYRODACTYL_DOCKER_DEV: 'true'
wings:
# The default Wings image doesn't work on macOS Docker
# This fork simply removes the incompatible `io.priority` cgroup v2 flag
# For Linux users, you can use the default image by uncommenting the line below
# image: ghcr.io/pterodactyl/wings:latest
image: ghcr.io/he3als/wings-mac:latest
image: ghcr.io/pterodactyl/wings:latest
# image: ghcr.io/he3als/wings-mac:latest
restart: always
ports:
- '8080:8080'
@@ -102,20 +107,20 @@ services:
volumes:
- '/var/run/docker.sock:/var/run/docker.sock'
- '/etc/ssl/certs:/etc/ssl/certs:ro'
- './srv/pterodactyl/config/:/etc/pterodactyl/'
- './local_docker/pterodactyl/config/:/etc/pterodactyl/'
# The volumes below need to be the exact same path in the container as on the host.
#
# The paths are currently hardcoded in the container on first run, meaning if you move
# this repo on your host, you'll need to delete "srv" folder so the paths can be recreated.
# this repo on your host, you'll need to delete "local_docker" folder so the paths can be recreated.
#
# If you change these from $PWD, make sure to update `WINGS_DIR` in the panel service too.
# Do not change anything but the $PWD part as this is also hardcoded in the container.
- '${PWD}/srv/wings/tmp/:${PWD}/srv/wings/tmp/'
- '${PWD}/srv/wings/docker/containers/:${PWD}/srv/wings/docker/containers/'
- '${PWD}/srv/wings/:${PWD}/srv/wings/'
- '${PWD}/srv/wings/logs/:${PWD}/srv/wings/logs/'
- '${PWD}/local_docker/wings/tmp/:${PWD}/local_docker/wings/tmp/'
- '${PWD}/local_docker/wings/docker/containers/:${PWD}/local_docker/wings/docker/containers/'
- '${PWD}/local_docker/wings/:${PWD}/local_docker/wings/'
- '${PWD}/local_docker/wings/logs/:${PWD}/local_docker/wings/logs/'
networks:
default:

View File

@@ -13,6 +13,7 @@ import {
import PageContentBlock from '@/components/elements/PageContentBlock';
import Pagination from '@/components/elements/Pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/elements/Tabs';
import { PageListContainer } from '@/components/elements/pages/PageList';
import getServers from '@/api/getServers';
import { PaginatedResult } from '@/api/http';
@@ -75,7 +76,13 @@ const DashboardContainer = () => {
<div className='flex gap-4'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className='flex items-center gap-2 font-bold text-sm px-3 py-1 rounded-md bg-[#ffffff11] hover:bg-[#ffffff22] transition hover:duration-0 cursor-pointer'>
<button
style={{
background:
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
}}
className='flex items-center gap-2 font-bold text-sm px-4 py-3 rounded-full border-[1px] border-[#ffffff12] hover:bg-[#ffffff11] transition-colors duration-150 cursor-pointer'
>
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
@@ -152,45 +159,69 @@ const DashboardContainer = () => {
</div>
</MainPageHeader>
{!servers ? (
<></>
<div className='flex items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
</div>
) : (
<>
<TabsContent value='list'>
<Pagination data={servers} onPageSelect={setPage}>
{({ items }) =>
items.length > 0 ? (
items.map((server, index) => (
<div
key={server.uuid}
className='transform-gpu skeleton-anim-2 mb-4'
style={{
animationDelay: `${index * 50 + 50}ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<ServerRow className='flex-row' key={server.uuid} server={server} />
</div>
))
<PageListContainer>
{items.map((server, index) => (
<div
key={server.uuid}
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: `${index * 50 + 50}ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<ServerRow className='flex-row' key={server.uuid} server={server} />
</div>
))}
</PageListContainer>
) : (
<p className={`text-center text-sm text-zinc-400`}>
{showOnlyAdmin
? 'There are no other servers to display.'
: 'There are no servers associated with your account.'}
</p>
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg
className='w-8 h-8 text-zinc-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z'
clipRule='evenodd'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{showOnlyAdmin ? 'No other servers found' : 'No servers found'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{showOnlyAdmin
? 'There are no other servers to display.'
: 'There are no servers associated with your account.'}
</p>
</div>
</div>
)
}
</Pagination>
</TabsContent>
<TabsContent value='grid'>
<div className='grid grid-cols-2 gap-x-4'>
<Pagination data={servers} onPageSelect={setPage}>
{({ items }) =>
items.length > 0 ? (
items.map((server, index) => (
<Pagination data={servers} onPageSelect={setPage}>
{({ items }) =>
items.length > 0 ? (
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
{items.map((server, index) => (
<div
key={server.uuid}
className='transform-gpu skeleton-anim-2 mb-4 w-full'
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: `${index * 50 + 50}ms`,
animationTimingFunction:
@@ -203,17 +234,36 @@ const DashboardContainer = () => {
server={server}
/>
</div>
))
) : (
<p className={`text-center text-sm text-zinc-400`}>
{showOnlyAdmin
? 'There are no other servers to display.'
: 'There are no servers associated with your account.'}
</p>
)
}
</Pagination>
</div>
))}
</div>
) : (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg
className='w-8 h-8 text-zinc-400'
fill='currentColor'
viewBox='0 0 16 17'
>
<path
d='M1 3.1C1 2.54 1 2.26 1.109 2.046C1.20487 1.85785 1.35785 1.70487 1.546 1.609C1.76 1.5 2.04 1.5 2.6 1.5H5.4C5.96 1.5 6.24 1.5 6.454 1.609C6.64215 1.70487 6.79513 1.85785 6.891 2.046C7 2.26 7 2.54 7 3.1V3.9C7 4.46 7 4.74 6.891 4.954C6.79513 5.14215 6.64215 5.29513 6.454 5.391C6.24 5.5 5.96 5.5 5.4 5.5H2.6C2.04 5.5 1.76 5.5 1.546 5.391C1.35785 5.29513 1.20487 5.14215 1.109 4.954C1 4.74 1 4.46 1 3.9V3.1ZM9 3.1C9 2.54 9 2.26 9.109 2.046C9.20487 1.85785 9.35785 1.70487 9.546 1.609C9.76 1.5 10.04 1.5 10.6 1.5H13.4C13.96 1.5 14.24 1.5 14.454 1.609C14.6422 1.70487 14.7951 1.85785 14.891 2.046C15 2.26 15 2.54 15 3.1V3.9C15 4.46 15 4.74 14.891 4.954C14.7951 5.14215 14.6422 5.29513 14.454 5.391C14.24 5.5 13.96 5.5 13.4 5.5H10.6C10.04 5.5 9.76 5.5 9.546 5.391C9.35785 5.29513 9.20487 5.14215 9.109 4.954C9 4.74 9 4.46 9 3.9V3.1ZM1 8.1C1 7.54 1 7.26 1.109 7.046C1.20487 6.85785 1.35785 6.70487 1.546 6.609C1.76 6.5 2.04 6.5 2.6 6.5H5.4C5.96 6.5 6.24 6.5 6.454 6.609C6.64215 6.70487 6.79513 6.85785 6.891 7.046C7 7.26 7 7.54 7 8.1V8.9C7 9.46 7 9.74 6.891 9.954C6.79513 10.1422 6.64215 10.2951 6.454 10.391C6.24 10.5 5.96 10.5 5.4 10.5H2.6C2.04 10.5 1.76 10.5 1.546 10.391C1.35785 10.2951 1.20487 10.1422 1.109 9.954C1 9.74 1 9.46 1 8.9V8.1ZM9 8.1C9 7.54 9 7.26 9.109 7.046C9.20487 6.85785 9.35785 6.70487 9.546 6.609C9.76 6.5 10.04 6.5 10.6 6.5H13.4C13.96 6.5 14.24 6.5 14.454 6.609C14.6422 6.70487 14.7951 6.85785 14.891 7.046C15 7.26 15 7.54 15 8.1V8.9C15 9.46 15 9.74 14.891 9.954C14.7951 10.1422 14.6422 10.2951 14.454 10.391C14.24 10.5 13.96 10.5 13.4 10.5H10.6C10.04 10.5 9.76 10.5 9.546 10.391C9.35785 10.2951 9.20487 10.1422 9.109 9.954C9 9.74 9 9.46 9 8.9V8.1ZM1 13.1C1 12.54 1 12.26 1.109 12.046C1.20487 11.8578 1.35785 11.7049 1.546 11.609C1.76 11.5 2.04 11.5 2.6 11.5H5.4C5.96 11.5 6.24 11.5 6.454 11.609C6.64215 11.7049 6.79513 11.8578 6.891 12.046C7 12.26 7 12.54 7 13.1V13.9C7 14.46 7 14.74 6.891 14.954C6.79513 15.1422 6.64215 15.2951 6.454 15.391C6.24 15.5 5.96 15.5 5.4 15.5H2.6C2.04 15.5 1.76 15.5 1.546 15.391C1.35785 15.2951 1.20487 15.1422 1.109 14.954C1 14.74 1 14.46 1 13.9V13.1ZM9 13.1C9 12.54 9 12.26 9.109 12.046C9.20487 11.8578 9.35785 11.7049 9.546 11.609C9.76 11.5 10.04 11.5 10.6 11.5H13.4C13.96 11.5 14.24 11.5 14.454 11.609C14.6422 11.7049 14.7951 11.8578 14.891 12.046C15 12.26 15 12.54 15 13.1V13.9C15 14.46 15 14.74 14.891 14.954C14.7951 15.1422 14.6422 15.2951 14.454 15.391C14.24 15.5 13.96 15.5 13.4 15.5H10.6C10.04 15.5 9.76 15.5 9.546 15.391C9.35785 15.2951 9.20487 15.1422 9.109 14.954C9 14.74 9 14.46 9 13.9V13.1Z'
fill='currentColor'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{showOnlyAdmin ? 'No other servers found' : 'No servers found'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{showOnlyAdmin
? 'There are no other servers to display.'
: 'There are no servers associated with your account.'}
</p>
</div>
</div>
)
}
</Pagination>
</TabsContent>
</>
)}

View File

@@ -12,9 +12,8 @@ import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/ser
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && current / (limit * 1024 * 1024) >= 0.9;
const StatusIndicatorBox = styled.div<{ $status: ServerPowerState | undefined }>`
// background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.044) 100%);
border: 1px solid #ffffff07;
background: #ffffff11;
border: 1px solid #ffffff12;
transition: all 250ms ease-in-out;
padding: 1.75rem 2rem;
cursor: pointer;
@@ -25,8 +24,9 @@ const StatusIndicatorBox = styled.div<{ $status: ServerPowerState | undefined }>
position: relative;
&:hover {
border: 1px solid #ffffff11;
background: #ffffff18;
border: 1px solid #ffffff19;
background: #ffffff19;
transition-duration: 0ms;
}
& .status-bar {
@@ -121,7 +121,11 @@ const ServerRow = ({ server, className }: { server: Server; className?: string }
</div>
</div>
<div
className={`h-full hidden sm:flex items-center justify-center bg-[#ffffff09] border-[1px] border-[#ffffff11] shadow-xs rounded-md w-fit whitespace-nowrap px-4 py-2 text-sm gap-4`}
style={{
background:
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
}}
className={`h-full hidden sm:flex items-center justify-center border-[1px] border-[#ffffff12] shadow-md rounded-lg w-fit whitespace-nowrap px-4 py-2 text-sm gap-4`}
>
{!stats || isSuspended ? (
isSuspended ? (
@@ -151,44 +155,27 @@ const ServerRow = ({ server, className }: { server: Server; className?: string }
<Fragment>
<div className={`sm:flex hidden`}>
<div className={`flex justify-center gap-2 w-fit`}>
<p className='text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap'>CPU</p>
<p className='font-bold w-fit whitespace-nowrap'>{stats.cpuUsagePercent.toFixed(2)}%</p>
<p className='text-xs text-zinc-400 font-medium w-fit whitespace-nowrap'>CPU</p>
<p className='text-xs font-bold w-fit whitespace-nowrap'>
{stats.cpuUsagePercent.toFixed(2)}%
</p>
</div>
{/* <p className={`text-xs text-zinc-600 text-center mt-1`}>of {cpuLimit}</p> */}
</div>
<div className={`sm:flex hidden`}>
{/* <p className={`text-xs text-zinc-600 text-center mt-1`}>of {memoryLimit}</p> */}
<div className={`flex justify-center gap-2 w-fit`}>
<p className='text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap'>RAM</p>
<p className='font-bold w-fit whitespace-nowrap'>
<p className='text-xs text-zinc-400 font-medium w-fit whitespace-nowrap'>RAM</p>
<p className='text-xs font-bold w-fit whitespace-nowrap'>
{bytesToString(stats.memoryUsageInBytes, 0)}
</p>
</div>
</div>
<div className={`sm:flex hidden`}>
<div className={`flex justify-center gap-2 w-fit`}>
<p className='text-sm text-[#ffffff66] font-bold w-fit whitespace-nowrap'>Storage</p>
<p className='font-bold w-fit whitespace-nowrap'>
<p className='text-xs text-zinc-400 font-medium w-fit whitespace-nowrap'>Storage</p>
<p className='text-xs font-bold w-fit whitespace-nowrap'>
{bytesToString(stats.diskUsageInBytes, 0)}
</p>
</div>
{/* Pyro has unlimited storage */}
{/* ░░░░░▄▄▄▄▀▀▀▀▀▀▀▀▄▄▄▄▄▄░░░░░░░
░░░░░█░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▀▀▄░░░░
░░░░█░░░▒▒▒▒▒▒░░░░░░░░▒▒▒░░█░░░
░░░█░░░░░░▄██▀▄▄░░░░░▄▄▄░░░░█░░
░▄▀▒▄▄▄▒░█▀▀▀▀▄▄█░░░██▄▄█░░░░█░
█░▒█▒▄░▀▄▄▄▀░░░░░░░░█░░░▒▒▒▒▒░█
█░▒█░█▀▄▄░░░░░█▀░░░░▀▄░░▄▀▀▀▄▒█
░█░▀▄░█▄░█▀▄▄░▀░▀▀░▄▄▀░░░░█░░█░
░░█░░░▀▄▀█▄▄░█▀▀▀▄▄▄▄▀▀█▀██░█░░
░░░█░░░░██░░▀█▄▄▄█▄▄█▄████░█░░░
░░░░█░░░░▀▀▄░█░░░█░█▀██████░█░░
░░░░░▀▄░░░░░▀▀▄▄▄█▄█▄█▄█▄▀░░█░░
░░░░░░░▀▄▄░▒▒▒▒░░░░░░░░░░▒░░░█░
░░░░░░░░░░▀▀▄▄░▒▒▒▒▒▒▒▒▒▒░░░░█░
░░░░░░░░░░░░░░▀▄▄▄▄▄░░░░░░░░█░░ */}
{/* <p className={`text-xs text-zinc-600 text-center mt-1`}>of {diskLimit}</p> */}
</div>
</Fragment>
)}

View File

@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { JSX } from 'react';
import styled from 'styled-components';
const HeaderWrapper = styled.div``;
@@ -22,7 +23,7 @@ export const MainPageHeader: React.FC<MainPageHeaderProps> = ({
'flex',
direction === 'row' ? 'items-center flex-col md:flex-row' : 'items-start flex-col',
'justify-between',
'mb-8 gap-8 mt-8 md:mt-0 select-none',
'mb-4 gap-8 mt-8 md:mt-0 select-none',
)}
>
<div className='flex items-center gap-4 flex-wrap'>

View File

@@ -35,18 +35,23 @@ const BackupContainer = () => {
if (!backups || (error && isValidating)) {
return (
<ServerContentBlock title={'Backups'}>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem]'>Backups</h1>
<FlashMessageRender byKey={'backups'} />
<MainPageHeader title={'Backups'} />
<div className='flex items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
</div>
</ServerContentBlock>
);
}
return (
<ServerContentBlock title={'Backups'}>
<FlashMessageRender byKey={'backups'} />
<MainPageHeader title={'Backups'}>
<Can action={'backup.create'}>
<div className={`flex flex-col sm:flex-row items-center justify-end`}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{backupLimit > 0 && backups.backupCount > 0 && (
<p className={`text-sm text-zinc-300 mb-4 sm:mr-6 sm:mb-0 text-right`}>
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{backups.backupCount} of {backupLimit} backups
</p>
)}
@@ -54,19 +59,31 @@ const BackupContainer = () => {
</div>
</Can>
</MainPageHeader>
<FlashMessageRender byKey={'backups'} />
<Pagination data={backups} onPageSelect={setPage}>
{({ items }) =>
!items.length ? (
// Don't show any error messages if the server has no backups and the user cannot
// create additional ones for the server.
!backupLimit ? null : (
<p className={`text-center text-sm text-zinc-300`}>
{page > 1
? "Looks like we've run out of backups to show you, try going back a page."
: 'Your server does not have any backups.'}
</p>
)
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z'
clipRule='evenodd'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{backupLimit > 0 ? 'No backups found' : 'Backups unavailable'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{backupLimit > 0
? 'Your server does not have any backups. Create one to get started.'
: 'Backups cannot be created for this server.'}
</p>
</div>
</div>
) : (
<PageListContainer>
{items.map((backup) => (
@@ -76,9 +93,6 @@ const BackupContainer = () => {
)
}
</Pagination>
{backupLimit === 0 && (
<p className={`text-center text-sm text-zinc-300`}>Backups cannot be created for this server.</p>
)}
</ServerContentBlock>
);
};

View File

@@ -1,10 +1,10 @@
import { useState } from 'react';
import Can from '@/components/elements/Can';
import { ContextMenuContent, ContextMenuItem } from '@/components/elements/ContextMenu';
import Input from '@/components/elements/Input';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Dialog } from '@/components/elements/dialog';
import HugeIconsCloudUp from '@/components/elements/hugeicons/CloudUp';
import HugeIconsDelete from '@/components/elements/hugeicons/Delete';
import HugeIconsFileDownload from '@/components/elements/hugeicons/FileDownload';
import HugeIconsFileSecurity from '@/components/elements/hugeicons/FileSecurity';
@@ -160,40 +160,61 @@ const BackupContextMenu = ({ backup }: Props) => {
</Dialog.Confirm>
<SpinnerOverlay visible={loading} fixed />
{backup.isSuccessful ? (
<ContextMenuContent className='flex flex-col gap-1'>
<div className='flex flex-wrap gap-2'>
<Can action={'backup.download'}>
<ContextMenuItem className='flex gap-2' onSelect={doDownload}>
<HugeIconsFileDownload className='h-4! w-4!' fill='currentColor' />
Download Backup
</ContextMenuItem>
<button
type='button'
onClick={doDownload}
disabled={loading}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150 disabled:opacity-50'
>
<HugeIconsFileDownload className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Download</span>
</button>
</Can>
<Can action={'backup.restore'}>
<ContextMenuItem className='flex gap-2' onSelect={() => setModal('restore')}>
<HugeIconsFileDownload className='h-4! w-4!' fill='currentColor' />
Restore Backup
</ContextMenuItem>
<button
type='button'
onClick={() => setModal('restore')}
disabled={loading}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150 disabled:opacity-50'
>
<HugeIconsCloudUp className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Restore</span>
</button>
</Can>
<Can action={'backup.delete'}>
<>
<ContextMenuItem className='flex gap-2' onClick={onLockToggle}>
<HugeIconsFileSecurity className='h-4! w-4!' fill='currentColor' />
{backup.isLocked ? 'Unlock' : 'Lock'}
</ContextMenuItem>
{!backup.isLocked && (
<ContextMenuItem className='flex gap-2' onSelect={() => setModal('delete')}>
<HugeIconsDelete className='h-4! w-4!' fill='currentColor' />
Delete Backup
</ContextMenuItem>
)}
</>
<button
type='button'
onClick={onLockToggle}
disabled={loading}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150 disabled:opacity-50'
>
<HugeIconsFileSecurity className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>{backup.isLocked ? 'Unlock' : 'Lock'}</span>
</button>
{!backup.isLocked && (
<button
type='button'
onClick={() => setModal('delete')}
disabled={loading}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150 disabled:opacity-50'
>
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Delete</span>
</button>
)}
</Can>
</ContextMenuContent>
</div>
) : (
<button
type='button'
onClick={() => setModal('delete')}
className={`text-zinc-200 transition-colors duration-150 hover:text-zinc-100 p-2 cursor-pointer`}
disabled={loading}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150 disabled:opacity-50'
>
Delete Backup
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Delete</span>
</button>
)}
</>

View File

@@ -53,65 +53,67 @@ const BackupRow = ({ backup }: Props) => {
});
return (
<ContextMenu>
<ContextMenuTrigger>
<PageListItem>
<div className={`flex-auto max-w-full box-border`}>
<div className='flex flex-row align-middle items-center gap-6 truncate'>
<div className='flex-none'>
{backup.completedAt === null ? (
<Spinner size={'small'} />
) : backup.isLocked ? (
<FontAwesomeIcon icon={faLock} className='text-red-500' />
) : (
<FontAwesomeIcon icon={faFile} />
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 mb-2'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
{backup.completedAt === null ? (
<Spinner size={'small'} />
) : backup.isLocked ? (
<FontAwesomeIcon icon={faLock} className='text-red-400 w-4 h-4' />
) : backup.isSuccessful ? (
<FontAwesomeIcon icon={faFile} className='text-green-400 w-4 h-4' />
) : (
<FontAwesomeIcon icon={faFile} className='text-red-400 w-4 h-4' />
)}
</div>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 mb-1'>
{backup.completedAt !== null && !backup.isSuccessful && (
<span className='bg-red-500 py-1 px-2 rounded-full text-white text-xs uppercase font-medium'>
Failed
</span>
)}
<h3 className='text-base font-medium text-zinc-100 truncate'>{backup.name}</h3>
{backup.isLocked && (
<span className='text-xs text-red-400 font-medium bg-red-500/10 px-2 py-1 rounded'>
Locked
</span>
)}
</div>
<div className={`flex items-center w-full md:flex-1`}>
<div className={`flex flex-col`}>
<div className={`flex items-center text-sm mb-1`}>
{backup.completedAt !== null && !backup.isSuccessful && (
<span
className={`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}
>
Failed
</span>
)}
<div className={`flex gap-2 items-center justify-center`}>
<p className='break-words truncate text-lg'>{backup.name}</p>
</div>
</div>
{backup.checksum && (
<p className={`mt-1 md:mt-0 text-xs text-zinc-400 font-mono truncate`}>
{backup.checksum}
</p>
)}
</div>
</div>
{backup.checksum && (
<p className='text-sm text-zinc-400 font-mono truncate'>{backup.checksum}</p>
)}
</div>
</div>
<div className='flex flex-row justify-center font-medium sm:justify-between min-w-full lg:w-96 sm:min-w-40'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm'>
{backup.completedAt !== null && backup.isSuccessful && (
<>
<span className={`text-xs sm:flex-initial sm:ml-0`}>{bytesToString(backup.bytes)}</span>
<p className={`text-xs inline sm:hidden`}>,&nbsp;</p>
</>
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Size</p>
<p className='text-zinc-300 font-medium'>{bytesToString(backup.bytes)}</p>
</div>
)}
<p
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
className={`text-xs sm:flex-initial`}
>
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Created</p>
<p
className='text-zinc-300 font-medium'
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
>
{formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
</p>
</div>
</div>
</div>
<div className='flex items-center gap-2 sm:flex-col sm:gap-3'>
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
{!backup.completedAt ? <></> : <BackupContextMenu backup={backup} />}
{backup.completedAt ? <BackupContextMenu backup={backup} /> : null}
</Can>
</PageListItem>
</ContextMenuTrigger>
</ContextMenu>
</div>
</div>
</div>
);
};

View File

@@ -94,6 +94,7 @@ const CreateBackupButton = () => {
(data) => ({ ...data!, items: data!.items.concat(backup), backupCount: data!.backupCount + 1 }),
false,
);
setSubmitting(false);
setVisible(false);
})
.catch((error) => {
@@ -122,7 +123,7 @@ const CreateBackupButton = () => {
background:
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
}}
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer'
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer hover:bg-[#ffffff11] transition-colors duration-150'
onClick={() => setVisible(true)}
>
New Backup

View File

@@ -10,11 +10,13 @@ interface ChartBlockProps {
// eslint-disable-next-line react/display-name
export default ({ title, legend, children }: ChartBlockProps) => (
<div className={clsx(styles.chart_container, 'group p-8!')}>
<div className={'flex items-center justify-between mb-4'}>
<h3 className={'font-extrabold text-sm'}>{title}</h3>
{legend && <div className={'text-sm flex items-center'}>{legend}</div>}
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 group h-full shadow-sm'>
<div className={'flex items-center justify-between mb-3 sm:mb-4'}>
<h3 className={'font-semibold text-sm text-zinc-100 group-hover:text-white transition-colors duration-150'}>
{title}
</h3>
{legend && <div className={'text-xs sm:text-sm flex items-center text-zinc-400'}>{legend}</div>}
</div>
<div className={'z-10 overflow-hidden rounded-lg'}>{children}</div>
<div className={'z-10 overflow-hidden rounded-lg h-40 sm:h-48'}>{children}</div>
</div>
);

View File

@@ -45,16 +45,22 @@ const terminalProps: ITerminalOptions = {
disableStdin: true,
cursorStyle: 'underline',
allowTransparency: true,
fontSize: 12,
fontFamily: 'monospace, monospace',
// rows: 30,
fontSize: window.innerWidth < 640 ? 11 : 12,
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
theme: theme,
};
const Console = () => {
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pyrodactyl~ \u001b[0m';
const ref = useRef<HTMLDivElement>(null);
const terminal = useMemo(() => new Terminal({ ...terminalProps, rows: 30 }), []);
const terminal = useMemo(
() =>
new Terminal({
...terminalProps,
rows: window.innerWidth < 640 ? 20 : 25,
}),
[],
);
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
const webLinksAddon = new WebLinksAddon();
@@ -145,6 +151,9 @@ const Console = () => {
'resize',
debounce(() => {
if (terminal.element) {
// Update font size based on window width
const newFontSize = window.innerWidth < 640 ? 11 : 12;
terminal.options.fontSize = newFontSize;
fitAddon.fit();
}
}, 100),
@@ -193,39 +202,25 @@ const Console = () => {
}, [connected, instance]);
return (
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
height: '100%',
animationDelay: `250ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<div className={clsx(styles.terminal, 'relative')}>
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl hover:border-[#ffffff20] transition-all duration-150 overflow-hidden shadow-sm'>
<div className='relative'>
<SpinnerOverlay visible={!connected} size={'large'} />
<div
className={clsx(styles.terminalContainer, styles.overflows_container, {
'rounded-b': !canSendCommands,
})}
>
<div className={'h-full'}>
<div id={styles.terminal} ref={ref} />
<div className='bg-[#131313] min-h-[280px] sm:min-h-[380px] p-3 sm:p-4 font-mono overflow-hidden'>
<div className='h-full w-full'>
<div ref={ref} className='h-full w-full' />
</div>
</div>
{canSendCommands && (
<div className={clsx('relative', styles.overflows_container)}>
<div className='relative border-t-[1px] border-[#ffffff11] bg-[#0f0f0f]'>
<input
className={clsx('peer', styles.command_input)}
type={'text'}
placeholder={'Enter a command'}
aria-label={'Console command input.'}
className='w-full bg-transparent px-3 py-2.5 sm:px-4 sm:py-3 font-mono text-xs sm:text-sm text-zinc-100 placeholder-zinc-500 border-0 outline-none focus:ring-0 focus:outline-none focus:bg-[#1a1a1a] transition-colors duration-150'
type='text'
placeholder='Enter a command...'
aria-label='Console command input.'
disabled={!instance || !connected}
onKeyDown={handleCommandKeyDown}
autoCorrect={'off'}
autoCapitalize={'none'}
autoCorrect='off'
autoCapitalize='none'
/>
</div>
)}

View File

@@ -1,3 +1,5 @@
import { faChartBar, faServer, faTerminal } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { memo } from 'react';
import isEqual from 'react-fast-compare';
@@ -16,13 +18,11 @@ import { ServerContext } from '@/state/server';
import Features from '@feature/Features';
import { StatusPill } from './StatusPill';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
const ServerConsoleContainer = () => {
const name = ServerContext.useStoreState((state) => state.server.data!.name);
const description = ServerContext.useStoreState((state) => state.server.data!.description);
// const description = ServerContext.useStoreState((state) => state.server.data!.description);
const isInstalling = ServerContext.useStoreState((state) => state.server.isInstalling);
const isTransferring = ServerContext.useStoreState((state) => state.server.data!.isTransferring);
const eggFeatures = ServerContext.useStoreState((state) => state.server.data!.eggFeatures, isEqual);
@@ -30,34 +30,134 @@ const ServerConsoleContainer = () => {
return (
<ServerContentBlock title={'Home'}>
<div className='w-full h-full min-h-full flex-1 flex flex-col gap-4'>
<div className='w-full h-full min-h-full flex-1 flex flex-col px-2 sm:px-0'>
{(isNodeUnderMaintenance || isInstalling || isTransferring) && (
<Alert type={'warning'} className={'mb-4'}>
{isNodeUnderMaintenance
? 'The node of this server is currently under maintenance and all actions are unavailable.'
: isInstalling
? 'This server is currently running its installation process and most actions are unavailable.'
: 'This server is currently being transferred to another node and all actions are unavailable.'}
</Alert>
<div
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
style={{
animationDelay: '50ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<Alert type={'warning'}>
{isNodeUnderMaintenance
? 'The node of this server is currently under maintenance and all actions are unavailable.'
: isInstalling
? 'This server is currently running its installation process and most actions are unavailable.'
: 'This server is currently being transferred to another node and all actions are unavailable.'}
</Alert>
</div>
)}
<MainPageHeader title={name} titleChildren={<StatusPill />}>
<PowerButtons className='skeleton-anim-2 duration-75 flex gap-1 items-center justify-center' />
</MainPageHeader>
{description && (
<h2 className='text-sm -mt-8'>
<span className='opacity-50'>{description}</span>
</h2>
)}
<ServerDetailsBlock />
<Console />
<div className={'grid grid-cols-1 md:grid-cols-3 gap-4'}>
<Spinner.Suspense>
<StatGraphs />
</Spinner.Suspense>
<div
className='transform-gpu skeleton-anim-2 mb-3 sm:mb-4'
style={{
animationDelay: '75ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<MainPageHeader title={name}>
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '100ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<PowerButtons className='flex gap-1 items-center justify-center' />
</div>
</MainPageHeader>
</div>
<div className='flex flex-col gap-3 sm:gap-4'>
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '125ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
<div className='flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4'>
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
<FontAwesomeIcon
icon={faServer}
className='w-2.5 h-2.5 sm:w-3 sm:h-3 text-zinc-400'
/>
</div>
<h3 className='text-sm sm:text-base font-semibold text-zinc-100'>Server Resources</h3>
</div>
<ServerDetailsBlock />
</div>
</div>
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '175ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
<div className='flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4'>
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
<FontAwesomeIcon
icon={faTerminal}
className='w-2.5 h-2.5 sm:w-3 sm:h-3 text-zinc-400'
/>
</div>
<h3 className='text-sm sm:text-base font-semibold text-zinc-100'>Console</h3>
</div>
<Console />
</div>
</div>
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '225ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 shadow-sm'>
<div className='flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4'>
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
<FontAwesomeIcon
icon={faChartBar}
className='w-2.5 h-2.5 sm:w-3 sm:h-3 text-zinc-400'
/>
</div>
<h3 className='text-sm sm:text-base font-semibold text-zinc-100'>
Performance Metrics
</h3>
</div>
<div className={'grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4'}>
<Spinner.Suspense>
<StatGraphs />
</Spinner.Suspense>
</div>
</div>
</div>
<div
className='transform-gpu skeleton-anim-2'
style={{
animationDelay: '275ms',
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
>
<ErrorBoundary>
<Features enabled={eggFeatures} />
</ErrorBoundary>
</div>
</div>
<ErrorBoundary>
<Features enabled={eggFeatures} />
</ErrorBoundary>
</div>
</ServerContentBlock>
);

View File

@@ -79,13 +79,11 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
});
return (
<div className={clsx('flex md:flex-row gap-4 flex-col', className)}>
<div className={clsx('grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4', className)}>
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `150ms`,
animationDelay: `50ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
@@ -97,9 +95,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `175ms`,
animationDelay: `75ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
@@ -115,9 +111,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `200ms`,
animationDelay: `100ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}
@@ -133,9 +127,7 @@ const ServerDetailsBlock = ({ className }: { className?: string }) => {
<div
className='transform-gpu skeleton-anim-2'
style={{
display: 'flex',
width: '100%',
animationDelay: `225ms`,
animationDelay: `125ms`,
animationTimingFunction:
'linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1)',
}}

View File

@@ -14,10 +14,21 @@ interface StatBlockProps {
const StatBlock = ({ title, copyOnClick, className, children }: StatBlockProps) => {
return (
<CopyOnClick text={copyOnClick}>
<div className={clsx(styles.stat_block, 'bg-[#ffffff09] border-[1px] border-[#ffffff11]', className)}>
<div
className={clsx(
'bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-3 sm:p-4 hover:border-[#ffffff20] transition-all duration-150 cursor-pointer group shadow-sm',
className,
)}
>
<div className={'flex flex-col justify-center overflow-hidden w-full'}>
<p className={'leading-tight text-xs md:text-sm text-zinc-400'}>{title}</p>
<div className={'text-[32px] font-extrabold leading-[98%] tracking-[-0.07rem] w-full truncate'}>
<p className={'leading-tight text-xs text-zinc-400 mb-2 uppercase tracking-wide font-medium'}>
{title}
</p>
<div
className={
'text-lg sm:text-xl font-bold leading-tight tracking-tight w-full truncate text-zinc-100 group-hover:text-white transition-colors duration-150'
}
>
{children}
</div>
</div>

View File

@@ -46,6 +46,7 @@ const CreateDatabaseButton = () => {
})
.then((database) => {
appendDatabase(database);
setSubmitting(false);
setVisible(false);
})
.catch((error) => {
@@ -106,7 +107,7 @@ const CreateDatabaseButton = () => {
background:
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
}}
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer'
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer hover:bg-[#ffffff11] transition-colors duration-150'
onClick={() => setVisible(true)}
>
New Database

View File

@@ -161,74 +161,64 @@ const DatabaseRow = ({ database }: Props) => {
</div>
</Modal>
{/* Title */}
<div className={`flex-auto box-border min-w-fit`}>
<div className='flex flex-row flex-none align-middle items-center gap-6'>
<FontAwesomeIcon icon={faDatabase} className='flex-none' />
<div>
<CopyOnClick text={database.name}>
<p className='text-lg'>{database.name}</p>
</CopyOnClick>
<CopyOnClick text={database.connectionString}>
<p className={`text-xs text-zinc-400 font-mono`}>{database.connectionString}</p>
</CopyOnClick>
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-3 mb-2'>
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
<FontAwesomeIcon icon={faDatabase} className='text-zinc-400 w-4 h-4' />
</div>
<div className='min-w-0 flex-1'>
<CopyOnClick text={database.name}>
<h3 className='text-base font-medium text-zinc-100 truncate'>{database.name}</h3>
</CopyOnClick>
</div>
</div>
<div className='grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm'>
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Endpoint</p>
<CopyOnClick text={database.connectionString}>
<p className='text-zinc-300 font-mono truncate'>{database.connectionString}</p>
</CopyOnClick>
</div>
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>From</p>
<CopyOnClick text={database.allowConnectionsFrom}>
<p className='text-zinc-300 font-mono truncate'>{database.allowConnectionsFrom}</p>
</CopyOnClick>
</div>
<div>
<p className='text-xs text-zinc-500 uppercase tracking-wide mb-1'>Username</p>
<CopyOnClick text={database.username}>
<p className='text-zinc-300 font-mono truncate'>{database.username}</p>
</CopyOnClick>
</div>
</div>
</div>
<div className='flex items-center gap-2 sm:flex-col sm:gap-3'>
<button
type='button'
onClick={() => setConnectionVisible(true)}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150'
>
<FontAwesomeIcon icon={faEye} className='w-4 h-4' />
<span className='hidden sm:inline'>Details</span>
</button>
<Can action={'database.delete'}>
<button
type='button'
onClick={() => setVisible(true)}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150'
>
<FontAwesomeIcon icon={faTrashAlt} className='w-4 h-4' />
<span className='hidden sm:inline'>Delete</span>
</button>
</Can>
</div>
</div>
</div>
{/* Properties + buttons */}
<div className={`flex flex-col items-center sm:gap-12 gap-4 sm:flex-row`}>
<div className='flex flex-wrap gap-4 justify-center m-auto'>
<For
each={[
{ label: 'Endpoint', value: database.connectionString },
{ label: 'From', value: database.allowConnectionsFrom },
{ label: 'Username', value: database.username },
]}
memo
>
{(db, index) => (
<div key={index} className='text-center'>
<CopyOnClick text={db.value}>
<p className='text-sm'>{db.value}</p>
</CopyOnClick>
<p className='mt-1 text-xs text-zinc-500 uppercase select-none'>{db.label}</p>
</div>
)}
</For>
</div>
<div className='flex align-middle items-center justify-center'>
<button
type={'button'}
aria-label={'View database connection details'}
className={`text-sm p-2 text-zinc-500 hover:text-zinc-100 transition-colors duration-150 mr-4 flex align-middle items-center justify-center flex-col cursor-pointer`}
onClick={() => setConnectionVisible(true)}
>
<FontAwesomeIcon icon={faEye} className={`px-5`} size='lg' />
Details
</button>
<Can action={'database.delete'}>
<button
type={'button'}
aria-label={'Delete database'}
className={`text-sm p-2 text-zinc-500 hover:text-red-600 transition-colors duration-150 flex align-middle items-center justify-center flex-col cursor-pointer`}
onClick={() => setVisible(true)}
>
<FontAwesomeIcon icon={faTrashAlt} className={`px-5`} size='lg' />
Delete
</button>
</Can>
</div>
{/* <Button onClick={() => setConnectionVisible(true)}>
<FontAwesomeIcon icon={faEye} fixedWidth />
</Button>
<Can action={'database.delete'}>
<Button color={'red'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faTrashAlt} fixedWidth />
</Button>
</Can> */}
</div>
</>
);
};

View File

@@ -45,9 +45,9 @@ const DatabasesContainer = () => {
<FlashMessageRender byKey={'databases'} />
<MainPageHeader title={'Databases'}>
<Can action={'database.create'}>
<div className={`flex flex-col sm:flex-row items-center justify-end`}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{databaseLimit > 0 && databases.length > 0 && (
<p className={`text-sm text-zinc-300 mb-4 sm:mr-6 sm:mb-0 text-right`}>
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{databases.length} of {databaseLimit} databases
</p>
)}
@@ -56,26 +56,38 @@ const DatabasesContainer = () => {
</Can>
</MainPageHeader>
{!databases.length && loading ? null : (
<>
{databases.length > 0 ? (
<PageListContainer data-pyro-backups>
<For each={databases} memo>
{(database, index) => (
<PageListItem key={index}>
<DatabaseRow key={database.id} database={database} />
</PageListItem>
)}
</For>
</PageListContainer>
) : (
<p className={`text-center text-sm text-zinc-300`}>
{!databases.length && loading ? (
<div className='flex items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
</div>
) : databases.length > 0 ? (
<PageListContainer data-pyro-databases>
<For each={databases} memo>
{(database, index) => <DatabaseRow key={database.id} database={database} />}
</For>
</PageListContainer>
) : (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z'
clipRule='evenodd'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{databaseLimit > 0 ? 'No databases found' : 'Databases unavailable'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{databaseLimit > 0
? 'Your server does not have any databases.'
? 'Your server does not have any databases. Create one to get started.'
: 'Databases cannot be created for this server.'}
</p>
)}
</>
</div>
</div>
)}
</ServerContentBlock>
);

View File

@@ -8,7 +8,6 @@ import CopyOnClick from '@/components/elements/CopyOnClick';
import { Textarea } from '@/components/elements/Input';
import InputSpinner from '@/components/elements/InputSpinner';
import { Button } from '@/components/elements/button/index';
import { PageListItem } from '@/components/elements/pages/PageList';
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
import { ip } from '@/lib/formatters';
@@ -57,62 +56,60 @@ const AllocationRow = ({ allocation }: Props) => {
};
return (
<PageListItem>
<div className={'flex items-center w-full md:w-auto'}>
<div className={'mr-4 flex-1 md:w-40'}>
{allocation.alias ? (
<CopyOnClick text={allocation.alias}>
<div>
<Code dark className={'w-40 truncate'}>
{allocation.alias}
</Code>
</div>
</CopyOnClick>
<div className='bg-linear-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff15] p-4 sm:p-5 rounded-xl hover:border-[#ffffff20] transition-all'>
<div className='flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3'>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 flex-wrap mb-2'>
{allocation.alias ? (
<CopyOnClick text={allocation.alias}>
<h3 className='text-lg font-medium text-zinc-100 font-mono'>{allocation.alias}</h3>
</CopyOnClick>
) : (
<CopyOnClick text={ip(allocation.ip)}>
<h3 className='text-lg font-medium text-zinc-100 font-mono'>{ip(allocation.ip)}</h3>
</CopyOnClick>
)}
<span className='text-zinc-500'>:</span>
<span className='text-lg font-medium text-zinc-100 font-mono'>{allocation.port}</span>
</div>
<div className='min-h-[2rem] flex items-end'>
<InputSpinner visible={loading}>
<Textarea
className='w-full bg-transparent border-0 p-0 text-sm text-zinc-400 placeholder-zinc-500 resize-none focus:ring-0 focus:text-zinc-300'
placeholder='Add notes for this allocation...'
defaultValue={allocation.notes || undefined}
onChange={(e) => setAllocationNotes(e.currentTarget.value)}
rows={1}
/>
</InputSpinner>
</div>
</div>
<div className='flex items-center gap-2 flex-shrink-0'>
{allocation.isDefault ? (
<div className='flex items-center justify-center px-4 py-2 bg-brand/20 border border-brand/30 rounded-lg text-sm text-brand font-medium'>
Primary Port
</div>
) : (
<CopyOnClick text={ip(allocation.ip)}>
<div>
<Code dark>{ip(allocation.ip)}</Code>
</div>
</CopyOnClick>
<>
<Can action={'allocation.update'}>
<button
type='button'
onClick={setPrimaryAllocation}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-[#ffffff19] rounded-lg text-sm text-zinc-300 hover:text-zinc-100 transition-colors duration-150'
>
<span className='hidden sm:inline'>Make Primary</span>
<span className='sm:hidden'>Primary</span>
</button>
</Can>
<Can action={'allocation.delete'}>
<DeleteAllocationButton allocation={allocation.id} />
</Can>
</>
)}
<label className='uppercase text-xs mt-1 text-zinc-400 block px-1 select-none transition-colors duration-150'>
{allocation.alias ? 'Hostname' : 'IP Address'}
</label>
</div>
<div className={'w-16 md:w-24 overflow-hidden'}>
<Code dark>{allocation.port}</Code>
<label className='uppercase text-xs mt-1 text-zinc-400 block px-1 select-none transition-colors duration-150'>
Port
</label>
</div>
</div>
<div className={'mt-4 w-full md:mt-0 md:flex-1 md:w-auto'}>
<InputSpinner visible={loading}>
<Textarea
className={'bg-transparent p-4 rounded-xl w-full'}
placeholder={'Notes'}
defaultValue={allocation.notes || undefined}
onChange={(e) => setAllocationNotes(e.currentTarget.value)}
/>
</InputSpinner>
</div>
<div className={'flex justify-end space-x-4 mt-4 w-full md:mt-0 md:w-48'}>
{allocation.isDefault ? (
<p>Primary Port</p>
) : (
<>
<Can action={'allocation.delete'}>
<DeleteAllocationButton allocation={allocation.id} />
</Can>
<Can action={'allocation.update'}>
<Button.Text size={Button.Sizes.Small} onClick={setPrimaryAllocation}>
Make Primary
</Button.Text>
</Can>
</>
)}
</div>
</PageListItem>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Dialog } from '@/components/elements/dialog';
import HugeIconsDelete from '@/components/elements/hugeicons/Delete';
import deleteServerAllocation from '@/api/server/network/deleteServerAllocation';
import getServerAllocations from '@/api/swr/getServerAllocations';
@@ -47,8 +48,13 @@ const DeleteAllocationButton = ({ allocation }: Props) => {
>
This allocation will be immediately removed from your server.
</Dialog.Confirm>
<button className='cursor-pointer' onClick={() => setConfirm(true)}>
Delete
<button
type='button'
onClick={() => setConfirm(true)}
className='flex items-center justify-center gap-2 px-3 py-2 bg-[#ffffff11] hover:bg-red-600/20 rounded-lg text-sm text-zinc-300 hover:text-red-400 transition-colors duration-150'
>
<HugeIconsDelete className='h-4 w-4' fill='currentColor' />
<span className='hidden sm:inline'>Delete</span>
</button>
</>
);

View File

@@ -2,6 +2,7 @@ import { For } from 'million/react';
import { useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
import { MainPageHeader } from '@/components/elements/MainPageHeader';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
@@ -54,14 +55,15 @@ const NetworkContainer = () => {
};
return (
<ServerContentBlock showFlashKey={'server:network'} title={'Network'}>
<ServerContentBlock title={'Network'}>
<FlashMessageRender byKey={'server:network'} />
<MainPageHeader title={'Network'}>
{!data ? null : (
<>
{allocationLimit > 0 && (
<Can action={'allocation.create'}>
<div className={`flex flex-col sm:flex-row items-center justify-end`}>
<p className={`text-sm text-zinc-300 mb-4 sm:mr-6 sm:mb-0 text-right`}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{data.length} of {allocationLimit} allowed allocations
</p>
{allocationLimit > data.length && (
@@ -70,7 +72,7 @@ const NetworkContainer = () => {
background:
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
}}
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer'
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md cursor-pointer hover:bg-[#ffffff11] transition-colors duration-150'
onClick={onCreateAllocation}
>
New Allocation
@@ -82,16 +84,41 @@ const NetworkContainer = () => {
</>
)}
</MainPageHeader>
{!data ? null : (
<>
<PageListContainer data-pyro-network-container-allocations>
<For each={data} memo>
{(allocation) => (
<AllocationRow key={`${allocation.ip}:${allocation.port}`} allocation={allocation} />
)}
</For>
</PageListContainer>
</>
{!data ? (
<div className='flex items-center justify-center py-12'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-brand'></div>
</div>
) : data.length > 0 ? (
<PageListContainer data-pyro-network-container-allocations>
<For each={data} memo>
{(allocation) => (
<AllocationRow key={`${allocation.ip}:${allocation.port}`} allocation={allocation} />
)}
</For>
</PageListContainer>
) : (
<div className='flex flex-col items-center justify-center py-12 px-4'>
<div className='text-center'>
<div className='w-16 h-16 mx-auto mb-4 rounded-full bg-[#ffffff11] flex items-center justify-center'>
<svg className='w-8 h-8 text-zinc-400' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
clipRule='evenodd'
/>
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{allocationLimit > 0 ? 'No allocations found' : 'Allocations unavailable'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{allocationLimit > 0
? 'Your server does not have any network allocations. Create one to get started.'
: 'Network allocations cannot be created for this server.'}
</p>
</div>
</div>
)}
</ServerContentBlock>
);

View File

@@ -2,8 +2,7 @@
import { useStoreState } from 'easy-peasy';
import { on } from 'events';
import type React from 'react';
import { Fragment, Suspense, useEffect, useRef, useState } from 'react';
import React, { Fragment, Suspense, useEffect, useRef, useState } from 'react';
import { NavLink, Route, Routes, useLocation, useParams } from 'react-router-dom';
import routes from '@/routers/routes';
@@ -48,6 +47,84 @@ import { ServerContext } from '@/state/server';
const blank_egg_prefix = '@';
// Sidebar item components that check both permissions and feature limits
const DatabasesSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
({ id, onClick }, ref) => {
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0);
// Hide if no database access (limit is 0)
if (databaseLimit === 0) return null;
return (
<Can action={'database.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
ref={ref}
to={`/server/${id}/databases`}
onClick={onClick}
end
>
<HugeIconsDatabase fill='currentColor' />
<p>Databases</p>
</NavLink>
</Can>
);
},
);
DatabasesSidebarItem.displayName = 'DatabasesSidebarItem';
const BackupsSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
({ id, onClick }, ref) => {
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0);
// Hide if no backup access (limit is 0)
if (backupLimit === 0) return null;
return (
<Can action={'backup.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
ref={ref}
to={`/server/${id}/backups`}
onClick={onClick}
end
>
<HugeIconsCloudUp fill='currentColor' />
<p>Backups</p>
</NavLink>
</Can>
);
},
);
BackupsSidebarItem.displayName = 'BackupsSidebarItem';
const NetworkingSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
({ id, onClick }, ref) => {
const allocationLimit = ServerContext.useStoreState(
(state) => state.server.data?.featureLimits.allocations ?? 0,
);
// Hide if no allocation access (limit is 0)
if (allocationLimit === 0) return null;
return (
<Can action={'allocation.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
ref={ref}
to={`/server/${id}/network`}
onClick={onClick}
end
>
<HugeIconsConnections fill='currentColor' />
<p>Networking</p>
</NavLink>
</Can>
);
},
);
NetworkingSidebarItem.displayName = 'NetworkingSidebarItem';
interface Egg {
object: string;
attributes: {
@@ -453,42 +530,17 @@ const ServerRouter = () => {
<p>Files</p>
</NavLink>
</Can>
<Can action={'database.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
ref={NavigationDatabases}
to={`/server/${id}/databases`}
onClick={toggleSidebar}
end
>
<HugeIconsDatabase fill='currentColor' />
<p>Databases</p>
</NavLink>
</Can>
<Can action={'backup.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
ref={NavigationBackups}
to={`/server/${id}/backups`}
onClick={toggleSidebar}
end
>
<HugeIconsCloudUp fill='currentColor' />
<p>Backups</p>
</NavLink>
</Can>
<Can action={'allocation.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
ref={NavigationNetworking}
to={`/server/${id}/network`}
onClick={toggleSidebar}
end
>
<HugeIconsConnections fill='currentColor' />
<p>Networking</p>
</NavLink>
</Can>
<DatabasesSidebarItem
id={id}
ref={NavigationDatabases}
onClick={toggleSidebar}
/>
<BackupsSidebarItem id={id} ref={NavigationBackups} onClick={toggleSidebar} />
<NetworkingSidebarItem
id={id}
ref={NavigationNetworking}
onClick={toggleSidebar}
/>
<Can action={'user.*'} matchAny>
<NavLink
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'