4555 lines
207 KiB
React
4555 lines
207 KiB
React
import React, { useState, useEffect, useRef, useContext, useCallback, useMemo } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
// import { Card, CardContent } from "./components/ui/card";
|
|
import {
|
|
ChevronDown, X,
|
|
Server, Terminal, User, Settings, Plus, Trash2, Play, Square, RefreshCw,
|
|
Cpu, HardDrive, MemoryStick, Copy, Maximize2, Minimize2, AlertCircle,
|
|
ShoppingCart, Coins, Globe, Database, Activity, Menu, LogOut, Wifi, WifiOff,
|
|
Home, BookOpen,
|
|
BarChart3, HardDriveIcon, NetworkIcon, Clock, Info,
|
|
CheckCircle, Gamepad2,
|
|
Folder, Edit,
|
|
Check, Package,
|
|
Shield, MapPin,
|
|
ExternalLink,
|
|
AlertTriangle,
|
|
Tag,
|
|
Layers,
|
|
Coffee,
|
|
MessageCircle, Mail, Lock,
|
|
Eye, EyeOff,
|
|
ArrowRight,
|
|
} from "lucide-react";
|
|
|
|
import ServerCreationForm from '../features/servers/ServerCreationForm.jsx'
|
|
import ServerCard from '../features/servers/ServerCard.jsx'
|
|
import StoreSection from '../features/store/StoreSection.jsx'
|
|
import TicketSystem from '../features/support/TicketSystem.jsx'
|
|
import useWebSocket from '../hooks/useWebSocket.js';
|
|
import { AuthContext } from '../App.jsx';
|
|
import GameConsole from '../features/servers/GameConsole.jsx';
|
|
import {
|
|
colors,
|
|
parseServerDate,
|
|
formatCPU,
|
|
formatMemory,
|
|
getServerStatusInfo,
|
|
UnifiedTooltip,
|
|
Dropdown,
|
|
InlineDropdown,
|
|
Slider,
|
|
SectionCard,
|
|
ResourceGraph,
|
|
minecraftVersions,
|
|
MinecraftSettings,
|
|
DashboardViewShell,
|
|
PageHeaderBar,
|
|
StatTile,
|
|
DomainManager,
|
|
ActionButton,
|
|
DashboardEmptyState,
|
|
DashboardLoadingSpinner,
|
|
SettingsPanel,
|
|
} from '../components/DashboardComponents.jsx';
|
|
import { dashLayout } from '../theme/dashboardTokens.js';
|
|
import FileManager from '../features/servers/FileManager.jsx';
|
|
import BackupSection from '../features/servers/BackupSection.jsx';
|
|
import PluginStoreEmbedded from '../features/plugins/PluginStoreEmbedded.jsx';
|
|
import { usePanelCurrency } from '../hooks/usePanelCurrency.js';
|
|
|
|
|
|
import { API_BASE_URL, WS_BASE_URL as WS_URL } from '../config/api';
|
|
import branding from '../branding';
|
|
|
|
const NODE_IP = '85.237.179.121';
|
|
|
|
const currencyFlags = {
|
|
'USD': '🇺🇸', // US flag
|
|
'EUR': '🇪🇺', // European Union flag
|
|
'GBP': '🇬🇧', // UK flag
|
|
'PLN': '🇵🇱', // Poland flag
|
|
'PHP': '🇵🇭', // Philippines flag
|
|
'CZK': '🇨🇿', // Czech Republic flag
|
|
// Add more as needed
|
|
};
|
|
|
|
// Country list with codes and dialing codes (from your list)
|
|
const countries = [
|
|
{ code: 'AF', dialCode: '+93', name: 'Afghanistan' },
|
|
{ code: 'AL', dialCode: '+355', name: 'Albania' },
|
|
{ code: 'DZ', dialCode: '+213', name: 'Algeria' },
|
|
{ code: 'AD', dialCode: '+376', name: 'Andorra' },
|
|
{ code: 'AO', dialCode: '+244', name: 'Angola' },
|
|
{ code: 'AG', dialCode: '+1-268', name: 'Antigua and Barbuda' },
|
|
{ code: 'AR', dialCode: '+54', name: 'Argentina' },
|
|
{ code: 'AM', dialCode: '+374', name: 'Armenia' },
|
|
{ code: 'AU', dialCode: '+61', name: 'Australia' },
|
|
{ code: 'AT', dialCode: '+43', name: 'Austria' },
|
|
{ code: 'AZ', dialCode: '+994', name: 'Azerbaijan' },
|
|
{ code: 'BS', dialCode: '+1-242', name: 'Bahamas' },
|
|
{ code: 'BH', dialCode: '+973', name: 'Bahrain' },
|
|
{ code: 'BD', dialCode: '+880', name: 'Bangladesh' },
|
|
{ code: 'BB', dialCode: '+1-246', name: 'Barbados' },
|
|
{ code: 'BY', dialCode: '+375', name: 'Belarus' },
|
|
{ code: 'BE', dialCode: '+32', name: 'Belgium' },
|
|
{ code: 'BZ', dialCode: '+501', name: 'Belize' },
|
|
{ code: 'BJ', dialCode: '+229', name: 'Benin' },
|
|
{ code: 'BT', dialCode: '+975', name: 'Bhutan' },
|
|
{ code: 'BO', dialCode: '+591', name: 'Bolivia' },
|
|
{ code: 'BA', dialCode: '+387', name: 'Bosnia and Herzegovina' },
|
|
{ code: 'BW', dialCode: '+267', name: 'Botswana' },
|
|
{ code: 'BR', dialCode: '+55', name: 'Brazil' },
|
|
{ code: 'BN', dialCode: '+673', name: 'Brunei' },
|
|
{ code: 'BG', dialCode: '+359', name: 'Bulgaria' },
|
|
{ code: 'BF', dialCode: '+226', name: 'Burkina Faso' },
|
|
{ code: 'BI', dialCode: '+257', name: 'Burundi' },
|
|
{ code: 'KH', dialCode: '+855', name: 'Cambodia' },
|
|
{ code: 'CM', dialCode: '+237', name: 'Cameroon' },
|
|
{ code: 'CA', dialCode: '+1', name: 'Canada' },
|
|
{ code: 'CV', dialCode: '+238', name: 'Cape Verde' },
|
|
{ code: 'CF', dialCode: '+236', name: 'Central African Republic' },
|
|
{ code: 'TD', dialCode: '+235', name: 'Chad' },
|
|
{ code: 'CL', dialCode: '+56', name: 'Chile' },
|
|
{ code: 'CN', dialCode: '+86', name: 'China' },
|
|
{ code: 'CO', dialCode: '+57', name: 'Colombia' },
|
|
{ code: 'KM', dialCode: '+269', name: 'Comoros' },
|
|
{ code: 'CG', dialCode: '+242', name: 'Congo' },
|
|
{ code: 'CR', dialCode: '+506', name: 'Costa Rica' },
|
|
{ code: 'HR', dialCode: '+385', name: 'Croatia' },
|
|
{ code: 'CU', dialCode: '+53', name: 'Cuba' },
|
|
{ code: 'CY', dialCode: '+357', name: 'Cyprus' },
|
|
{ code: 'CZ', dialCode: '+420', name: 'Czech Republic' },
|
|
{ code: 'DK', dialCode: '+45', name: 'Denmark' },
|
|
{ code: 'DJ', dialCode: '+253', name: 'Djibouti' },
|
|
{ code: 'DM', dialCode: '+1-767', name: 'Dominica' },
|
|
{ code: 'DO', dialCode: '+1-809', name: 'Dominican Republic' },
|
|
{ code: 'EC', dialCode: '+593', name: 'Ecuador' },
|
|
{ code: 'EG', dialCode: '+20', name: 'Egypt' },
|
|
{ code: 'SV', dialCode: '+503', name: 'El Salvador' },
|
|
{ code: 'GQ', dialCode: '+240', name: 'Equatorial Guinea' },
|
|
{ code: 'ER', dialCode: '+291', name: 'Eritrea' },
|
|
{ code: 'EE', dialCode: '+372', name: 'Estonia' },
|
|
{ code: 'ET', dialCode: '+251', name: 'Ethiopia' },
|
|
{ code: 'FJ', dialCode: '+679', name: 'Fiji' },
|
|
{ code: 'FI', dialCode: '+358', name: 'Finland' },
|
|
{ code: 'FR', dialCode: '+33', name: 'France' },
|
|
{ code: 'GA', dialCode: '+241', name: 'Gabon' },
|
|
{ code: 'GM', dialCode: '+220', name: 'Gambia' },
|
|
{ code: 'GE', dialCode: '+995', name: 'Georgia' },
|
|
{ code: 'DE', dialCode: '+49', name: 'Germany' },
|
|
{ code: 'GH', dialCode: '+233', name: 'Ghana' },
|
|
{ code: 'GR', dialCode: '+30', name: 'Greece' },
|
|
{ code: 'GD', dialCode: '+1-473', name: 'Grenada' },
|
|
{ code: 'GT', dialCode: '+502', name: 'Guatemala' },
|
|
{ code: 'GN', dialCode: '+224', name: 'Guinea' },
|
|
{ code: 'GW', dialCode: '+245', name: 'Guinea-Bissau' },
|
|
{ code: 'GY', dialCode: '+592', name: 'Guyana' },
|
|
{ code: 'HT', dialCode: '+509', name: 'Haiti' },
|
|
{ code: 'HN', dialCode: '+504', name: 'Honduras' },
|
|
{ code: 'HU', dialCode: '+36', name: 'Hungary' },
|
|
{ code: 'IS', dialCode: '+354', name: 'Iceland' },
|
|
{ code: 'IN', dialCode: '+91', name: 'India' },
|
|
{ code: 'ID', dialCode: '+62', name: 'Indonesia' },
|
|
{ code: 'IR', dialCode: '+98', name: 'Iran' },
|
|
{ code: 'IQ', dialCode: '+964', name: 'Iraq' },
|
|
{ code: 'IE', dialCode: '+353', name: 'Ireland' },
|
|
{ code: 'IL', dialCode: '+972', name: 'Israel' },
|
|
{ code: 'IT', dialCode: '+39', name: 'Italy' },
|
|
{ code: 'JM', dialCode: '+1-876', name: 'Jamaica' },
|
|
{ code: 'JP', dialCode: '+81', name: 'Japan' },
|
|
{ code: 'JO', dialCode: '+962', name: 'Jordan' },
|
|
{ code: 'KZ', dialCode: '+7', name: 'Kazakhstan' },
|
|
{ code: 'KE', dialCode: '+254', name: 'Kenya' },
|
|
{ code: 'KI', dialCode: '+686', name: 'Kiribati' },
|
|
{ code: 'KP', dialCode: '+850', name: 'North Korea' },
|
|
{ code: 'KR', dialCode: '+82', name: 'South Korea' },
|
|
{ code: 'KW', dialCode: '+965', name: 'Kuwait' },
|
|
{ code: 'KG', dialCode: '+996', name: 'Kyrgyzstan' },
|
|
{ code: 'LA', dialCode: '+856', name: 'Laos' },
|
|
{ code: 'LV', dialCode: '+371', name: 'Latvia' },
|
|
{ code: 'LB', dialCode: '+961', name: 'Lebanon' },
|
|
{ code: 'LS', dialCode: '+266', name: 'Lesotho' },
|
|
{ code: 'LR', dialCode: '+231', name: 'Liberia' },
|
|
{ code: 'LY', dialCode: '+218', name: 'Libya' },
|
|
{ code: 'LI', dialCode: '+423', name: 'Liechtenstein' },
|
|
{ code: 'LT', dialCode: '+370', name: 'Lithuania' },
|
|
{ code: 'LU', dialCode: '+352', name: 'Luxembourg' },
|
|
{ code: 'MG', dialCode: '+261', name: 'Madagascar' },
|
|
{ code: 'MW', dialCode: '+265', name: 'Malawi' },
|
|
{ code: 'MY', dialCode: '+60', name: 'Malaysia' },
|
|
{ code: 'MV', dialCode: '+960', name: 'Maldives' },
|
|
{ code: 'ML', dialCode: '+223', name: 'Mali' },
|
|
{ code: 'MT', dialCode: '+356', name: 'Malta' },
|
|
{ code: 'MH', dialCode: '+692', name: 'Marshall Islands' },
|
|
{ code: 'MR', dialCode: '+222', name: 'Mauritania' },
|
|
{ code: 'MU', dialCode: '+230', name: 'Maurice' },
|
|
{ code: 'MX', dialCode: '+52', name: 'Mexico' },
|
|
{ code: 'FM', dialCode: '+691', name: 'Micronesia' },
|
|
{ code: 'MD', dialCode: '+373', name: 'Moldova' },
|
|
{ code: 'MC', dialCode: '+377', name: 'Monaco' },
|
|
{ code: 'MN', dialCode: '+976', name: 'Mongolia' },
|
|
{ code: 'ME', dialCode: '+382', name: 'Montenegro' },
|
|
{ code: 'MA', dialCode: '+212', name: 'Morocco' },
|
|
{ code: 'MZ', dialCode: '+258', name: 'Mozambique' },
|
|
{ code: 'MM', dialCode: '+95', name: 'Myanmar' },
|
|
{ code: 'NA', dialCode: '+264', name: 'Namibia' },
|
|
{ code: 'NR', dialCode: '+674', name: 'Nauru' },
|
|
{ code: 'NP', dialCode: '+977', name: 'Nepal' },
|
|
{ code: 'NL', dialCode: '+31', name: 'Netherlands' },
|
|
{ code: 'NZ', dialCode: '+64', name: 'New Zealand' },
|
|
{ code: 'NI', dialCode: '+505', name: 'Nicaragua' },
|
|
{ code: 'NE', dialCode: '+227', name: 'Niger' },
|
|
{ code: 'NG', dialCode: '+234', name: 'Nigeria' },
|
|
{ code: 'MK', dialCode: '+389', name: 'North Macedonia' },
|
|
{ code: 'NO', dialCode: '+47', name: 'Norway' },
|
|
{ code: 'OM', dialCode: '+968', name: 'Oman' },
|
|
{ code: 'PK', dialCode: '+92', name: 'Pakistan' },
|
|
{ code: 'PW', dialCode: '+680', name: 'Palau' },
|
|
{ code: 'PA', dialCode: '+507', name: 'Panama' },
|
|
{ code: 'PG', dialCode: '+675', name: 'Papua New Guinea' },
|
|
{ code: 'PY', dialCode: '+595', name: 'Paraguay' },
|
|
{ code: 'PE', dialCode: '+51', name: 'Peru' },
|
|
{ code: 'PH', dialCode: '+63', name: 'Philippines' },
|
|
{ code: 'PL', dialCode: '+48', name: 'Poland' },
|
|
{ code: 'PT', dialCode: '+351', name: 'Portugal' },
|
|
{ code: 'QA', dialCode: '+974', name: 'Qatar' },
|
|
{ code: 'RO', dialCode: '+40', name: 'Romania' },
|
|
{ code: 'RU', dialCode: '+7', name: 'Russia' },
|
|
{ code: 'RW', dialCode: '+250', name: 'Rwanda' },
|
|
{ code: 'KN', dialCode: '+1-869', name: 'Saint Kitts and Nevis' },
|
|
{ code: 'LC', dialCode: '+1-758', name: 'Saint Lucia' },
|
|
{ code: 'VC', dialCode: '+1-784', name: 'Saint Vincent and the Grenadines' },
|
|
{ code: 'WS', dialCode: '+685', name: 'Samoa' },
|
|
{ code: 'SM', dialCode: '+378', name: 'San Marino' },
|
|
{ code: 'ST', dialCode: '+239', name: 'Sao Tome and Principe' },
|
|
{ code: 'SA', dialCode: '+966', name: 'Saudi Arabia' },
|
|
{ code: 'SN', dialCode: '+221', name: 'Senegal' },
|
|
{ code: 'RS', dialCode: '+381', name: 'Serbia' },
|
|
{ code: 'SC', dialCode: '+248', name: 'Seychelles' },
|
|
{ code: 'SL', dialCode: '+232', name: 'Sierra Leone' },
|
|
{ code: 'SG', dialCode: '+65', name: 'Singapore' },
|
|
{ code: 'SK', dialCode: '+421', name: 'Slovakia' },
|
|
{ code: 'SI', dialCode: '+386', name: 'Slovenia' },
|
|
{ code: 'SB', dialCode: '+677', name: 'Solomon Islands' },
|
|
{ code: 'SO', dialCode: '+252', name: 'Somalia' },
|
|
{ code: 'ZA', dialCode: '+27', name: 'South Africa' },
|
|
{ code: 'ES', dialCode: '+34', name: 'Spain' },
|
|
{ code: 'LK', dialCode: '+94', name: 'Sri Lanka' },
|
|
{ code: 'SD', dialCode: '+249', name: 'Sudan' },
|
|
{ code: 'SR', dialCode: '+597', name: 'Suriname' },
|
|
{ code: 'SE', dialCode: '+46', name: 'Sweden' },
|
|
{ code: 'CH', dialCode: '+41', name: 'Switzerland' },
|
|
{ code: 'SY', dialCode: '+963', name: 'Syria' },
|
|
{ code: 'TW', dialCode: '+886', name: 'Taiwan' },
|
|
{ code: 'TJ', dialCode: '+992', name: 'Tajikistan' },
|
|
{ code: 'TZ', dialCode: '+255', name: 'Tanzania' },
|
|
{ code: 'TH', dialCode: '+66', name: 'Thailand' },
|
|
{ code: 'TL', dialCode: '+670', name: 'Timor-Leste' },
|
|
{ code: 'TG', dialCode: '+228', name: 'Togo' },
|
|
{ code: 'TO', dialCode: '+676', name: 'Tonga' },
|
|
{ code: 'TT', dialCode: '+1-868', name: 'Trinidad and Tobago' },
|
|
{ code: 'TN', dialCode: '+216', name: 'Tunisia' },
|
|
{ code: 'TR', dialCode: '+90', name: 'Turkey' },
|
|
{ code: 'TM', dialCode: '+993', name: 'Turkmenistan' },
|
|
{ code: 'TV', dialCode: '+688', name: 'Tuvalu' },
|
|
{ code: 'UG', dialCode: '+256', name: 'Uganda' },
|
|
{ code: 'UA', dialCode: '+380', name: 'Ukraine' },
|
|
{ code: 'AE', dialCode: '+971', name: 'United Arab Emirates' },
|
|
{ code: 'GB', dialCode: '+44', name: 'United Kingdom' },
|
|
{ code: 'US', dialCode: '+1', name: 'United States' },
|
|
{ code: 'UY', dialCode: '+598', name: 'Uruguay' },
|
|
{ code: 'UZ', dialCode: '+998', name: 'Uzbekistan' },
|
|
{ code: 'VU', dialCode: '+678', name: 'Vanuatu' },
|
|
{ code: 'VA', dialCode: '+379', name: 'Vatican City' },
|
|
{ code: 'VE', dialCode: '+58', name: 'Venezuela' },
|
|
{ code: 'VN', dialCode: '+84', name: 'Vietnam' },
|
|
{ code: 'YE', dialCode: '+967', name: 'Yemen' },
|
|
{ code: 'ZM', dialCode: '+260', name: 'Zambia' },
|
|
{ code: 'ZW', dialCode: '+263', name: 'Zimbabwe' }
|
|
];
|
|
|
|
// Helper function to parse server creation date
|
|
// const parseServerDate = (dateString) => { ... } - MOVED TO SHARED
|
|
|
|
// Utility functions for metrics formatting
|
|
// const formatCPU = (cpuStr) => { ... } - MOVED TO SHARED
|
|
// const formatMemory = (memoryStr, totalMemoryGB = 1) => { ... } - MOVED TO SHARED
|
|
|
|
// Shared Dropdown Component
|
|
// const Dropdown = ({ value, options, onChange, placeholder, icon: Icon }) => { ... } - MOVED TO SHARED
|
|
|
|
// Unified Tooltip Component
|
|
// const UnifiedTooltip = ({ children, content, show = true, placement = 'top' }) => { ... } - MOVED TO SHARED
|
|
|
|
// File Manager Component with forwardRef
|
|
|
|
const fileManagerRef = React.createRef();
|
|
|
|
// ResourceGraph - MOVED TO SHARED
|
|
|
|
|
|
|
|
|
|
// Slider - MOVED TO SHARED
|
|
|
|
// SectionCard - MOVED TO SHARED
|
|
|
|
// Embedded Plugin Store Component for Dashboard
|
|
function DashboardPage({ setIsLoginOpen, setIsRegisterOpen, onCurrencyChange, pricing = {
|
|
price_cpu: 35.0,
|
|
price_ram: 35.0,
|
|
price_storage: 5.0,
|
|
multiplier_vps: 1.0,
|
|
multiplier_game: 0.8,
|
|
multiplier_website: 1.5,
|
|
multiplier_database: 1.3
|
|
} }) {
|
|
|
|
const navigate = useNavigate();
|
|
const { user, logout } = useContext(AuthContext);
|
|
|
|
const {
|
|
selectedCurrency,
|
|
setSelectedCurrency: setSelectedCurrencyBase,
|
|
currencies,
|
|
calculateCurrencyValue,
|
|
getCurrencySymbol,
|
|
} = usePanelCurrency();
|
|
|
|
const setSelectedCurrency = useCallback((v) => {
|
|
setSelectedCurrencyBase(v);
|
|
if (onCurrencyChange && typeof onCurrencyChange === 'function') {
|
|
onCurrencyChange(v);
|
|
}
|
|
}, [setSelectedCurrencyBase, onCurrencyChange]);
|
|
const [userData, setUserData] = useState({
|
|
...user,
|
|
email_verified: user?.email_verified || false
|
|
});
|
|
const [activeTab, setActiveTab] = useState('servers');
|
|
const [newPortProtocol, setNewPortProtocol] = useState('TCP');
|
|
const [newPortInternal, setNewPortInternal] = useState('');
|
|
const [showPortMappingGuide, setShowPortMappingGuide] = useState(false);
|
|
const [servers, setServers] = useState([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [selectedServer, setSelectedServer] = useState(null);
|
|
const [isEditingVersion, setIsEditingVersion] = useState(false);
|
|
const [isEditingLoader, setIsEditingLoader] = useState(false);
|
|
const [isEditingJava, setIsEditingJava] = useState(false);
|
|
const [newVersion, setNewVersion] = useState('');
|
|
const [newLoader, setNewLoader] = useState('');
|
|
const [newJavaVersion, setNewJavaVersion] = useState('');
|
|
const [isEditingResources, setIsEditingResources] = useState(false);
|
|
const [resourceValues, setResourceValues] = useState({ cpu: 1, ram: 1, storage: 10 });
|
|
const [toggleSFTPPassword, setToggleSFTPPassword] = useState(false);
|
|
|
|
// Initialize editing states when server is selected
|
|
useEffect(() => {
|
|
if (selectedServer) {
|
|
if (!isEditingVersion) setNewVersion(selectedServer.version || '');
|
|
if (!isEditingLoader) setNewLoader(selectedServer.loader || 'Paper');
|
|
if (!isEditingJava) setNewJavaVersion(selectedServer.java_version || 'Java 21');
|
|
setNewPortInternal('');
|
|
setShowPortMappingGuide(false);
|
|
}
|
|
}, [selectedServer, isEditingVersion, isEditingLoader, isEditingJava]);
|
|
|
|
// Compatibility logic for DashboardPage
|
|
useEffect(() => {
|
|
if (isEditingVersion && selectedServer?.image === 'minecraft' && newVersion) {
|
|
const parts = newVersion.split('.');
|
|
if (parts.length >= 2) {
|
|
const major = parseInt(parts[0]);
|
|
const minor = parseInt(parts[1]);
|
|
const patch = parts.length >= 3 ? parseInt(parts[2]) : 0;
|
|
|
|
// Auto-select Java version based on MC version ONLY if not manually editing Java
|
|
if (!isEditingJava) {
|
|
if (major >= 26) {
|
|
setNewJavaVersion('Java 25');
|
|
} else if (major === 1) {
|
|
if (minor >= 21 || (minor === 20 && patch >= 5)) {
|
|
setNewJavaVersion('Java 21');
|
|
} else if (minor >= 17) {
|
|
setNewJavaVersion('Java 17');
|
|
} else if (minor >= 16) {
|
|
setNewJavaVersion('Java 11');
|
|
} else if (minor >= 12) {
|
|
setNewJavaVersion('Java 8');
|
|
} else if (minor === 8) {
|
|
setNewJavaVersion('Java 7');
|
|
} else {
|
|
setNewJavaVersion('Java 8');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-correct incompatible loader (Fabric not on < 1.14)
|
|
if (newLoader === 'Fabric' && minor < 14) {
|
|
setNewLoader('Forge');
|
|
}
|
|
}
|
|
}
|
|
}, [newVersion, isEditingVersion, selectedServer?.image, isEditingJava, newLoader]);
|
|
|
|
const handleUpdateConfig = (type, value) => {
|
|
if (!selectedServer) return;
|
|
|
|
// We'll use the existing sendDashboardMessage logic
|
|
if (sendMessageRef.current) {
|
|
sendMessageRef.current({
|
|
type: 'change_server_version',
|
|
serverId: selectedServer.id,
|
|
version: type === 'version' ? value : newVersion,
|
|
loader: type === 'loader' ? value : newLoader,
|
|
java_version: type === 'java' ? value : newJavaVersion
|
|
});
|
|
}
|
|
|
|
// Close editing states
|
|
setIsEditingVersion(false);
|
|
setIsEditingLoader(false);
|
|
setIsEditingJava(false);
|
|
};
|
|
|
|
const [serverLogs, setServerLogs] = useState('');
|
|
const [isCreatingServer, setIsCreatingServer] = useState(false);
|
|
const [terminalOutput, setTerminalOutput] = useState('');
|
|
const [terminalInput, setTerminalInput] = useState('');
|
|
const [isTerminalConnected, setIsTerminalConnected] = useState(false);
|
|
const [isTerminalFullscreen, setIsTerminalFullscreen] = useState(false);
|
|
const [serverMetrics, setServerMetrics] = useState({ cpu: '0m', memory: '0Mi', cpu_percentage: 0, memory_percentage: 0 });
|
|
const [metricsHistory, setMetricsHistory] = useState({ cpu: [], memory: [] });
|
|
const [mcSettings, setMcSettings] = useState(null);
|
|
const [isMcSettingsLoading, setIsMcSettingsLoading] = useState(false);
|
|
const [userCredits, setUserCredits] = useState(user?.credits || 0);
|
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
|
const [activeServerDetailTab, setActiveServerDetailTab] = useState('overview');
|
|
|
|
// Ref for sendMessage to break circular dependency
|
|
const sendMessageRef = useRef(null);
|
|
|
|
const [displayNameModal, setDisplayNameModal] = useState({
|
|
open: false,
|
|
currentName: '',
|
|
field: '',
|
|
title: '',
|
|
label: '',
|
|
placeholder: ''
|
|
});
|
|
|
|
const [emailModal, setEmailModal] = useState({
|
|
open: false,
|
|
currentEmail: '',
|
|
title: '',
|
|
label: '',
|
|
placeholder: ''
|
|
});
|
|
|
|
const [passwordModal, setPasswordModal] = useState({
|
|
open: false,
|
|
title: '',
|
|
requireCurrent: true
|
|
});
|
|
|
|
const [alertModal, setAlertModal] = useState({
|
|
open: false,
|
|
title: '',
|
|
message: '',
|
|
type: 'info' // 'info', 'success', 'error', 'warning'
|
|
});
|
|
|
|
const [confirmModal, setConfirmModal] = useState({
|
|
open: false,
|
|
title: '',
|
|
message: '',
|
|
onConfirm: () => {},
|
|
type: 'danger',
|
|
confirmLabel: 'Confirm',
|
|
cancelLabel: 'Cancel',
|
|
});
|
|
|
|
const showAlert = useCallback((title, message, type = 'info') => {
|
|
setAlertModal({
|
|
open: true,
|
|
title,
|
|
message,
|
|
type
|
|
});
|
|
}, []);
|
|
|
|
const showConfirm = useCallback((title, message, onConfirm, type = 'danger', labels = {}) => {
|
|
setConfirmModal({
|
|
open: true,
|
|
title,
|
|
message,
|
|
onConfirm,
|
|
type,
|
|
confirmLabel: labels.confirmLabel || 'Confirm',
|
|
cancelLabel: labels.cancelLabel || 'Cancel',
|
|
});
|
|
}, []);
|
|
|
|
const [show2FASetup, setShow2FASetup] = useState(false);
|
|
const [twoFactorData, setTwoFactorData] = useState(null);
|
|
|
|
const [deleteAccountModal, setDeleteAccountModal] = useState(false);
|
|
const [deleteServerModal, setDeleteServerModal] = useState({ open: false, serverId: null });
|
|
const [convertDemoModal, setConvertDemoModal] = useState({ open: false, server: null, cost: 0 });
|
|
const [serverCreationProgress, setServerCreationProgress] = useState({
|
|
visible: false,
|
|
phase: '',
|
|
progress: 0,
|
|
message: ''
|
|
});
|
|
const [personalInfo, setPersonalInfo] = useState({
|
|
firstName: user?.first_name || '',
|
|
lastName: user?.last_name || '',
|
|
address: user?.address || '',
|
|
city: user?.city || '',
|
|
country: user?.country || '',
|
|
postalCode: user?.postal_code || '',
|
|
phone: user?.phone || ''
|
|
});
|
|
|
|
const [countryOptions, setCountryOptions] = useState([]);
|
|
const [selectedCountry, setSelectedCountry] = useState('');
|
|
const [phoneNumber, setPhoneNumber] = useState('');
|
|
const [detectingLocation, setDetectingLocation] = useState(false);
|
|
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
|
|
// Format countries for dropdown
|
|
useEffect(() => {
|
|
const formattedCountries = countries.map(country => ({
|
|
value: country.code,
|
|
label: `${country.name} (${country.dialCode})`,
|
|
dialCode: country.dialCode,
|
|
name: country.name
|
|
}));
|
|
|
|
// Sort alphabetically by country name
|
|
formattedCountries.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
setCountryOptions(formattedCountries);
|
|
|
|
// Set initial country from user data if available
|
|
if (user?.country) {
|
|
const userCountry = formattedCountries.find(c => c.value === user.country || c.name === user.country);
|
|
if (userCountry) {
|
|
setSelectedCountry(userCountry.value);
|
|
}
|
|
}
|
|
|
|
// Set initial phone number
|
|
if (user?.phone) {
|
|
setPhoneNumber(user.phone);
|
|
}
|
|
}, [user]);
|
|
|
|
// Function to detect user location
|
|
const detectUserLocation = useCallback(async () => {
|
|
setDetectingLocation(true);
|
|
try {
|
|
const response = await fetch('https://geoapi.info/api/geo');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch location');
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Location data:', data);
|
|
|
|
if (data.location?.country) {
|
|
const countryCode = data.location.country;
|
|
const foundCountry = countryOptions.find(c => c.value === countryCode);
|
|
|
|
if (foundCountry) {
|
|
setSelectedCountry(countryCode);
|
|
// Update personal info with detected country
|
|
setPersonalInfo(prev => ({
|
|
...prev,
|
|
country: foundCountry.name
|
|
}));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error detecting location:', error);
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Location Detection Failed",
|
|
message: "We couldn't automatically detect your location. Please select your country manually.",
|
|
type: "warning"
|
|
});
|
|
} finally {
|
|
setDetectingLocation(false);
|
|
}
|
|
}, [countryOptions]);
|
|
|
|
// Auto-detect location when country is not set
|
|
useEffect(() => {
|
|
if (!selectedCountry && countryOptions.length > 0) {
|
|
detectUserLocation();
|
|
}
|
|
}, [selectedCountry, countryOptions, detectUserLocation]);
|
|
|
|
// Get selected country info
|
|
const getSelectedCountry = () => {
|
|
return countryOptions.find(c => c.value === selectedCountry);
|
|
};
|
|
|
|
// Function to update user profile with phone and country
|
|
const updateUserProfile = async (profileData) => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/user/profile`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(profileData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
return { success: true, message: data.message || 'Profile updated successfully!' };
|
|
} else {
|
|
return { success: false, message: data.error || 'Failed to update profile' };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating profile:', error);
|
|
return { success: false, message: 'Error updating profile' };
|
|
}
|
|
};
|
|
|
|
const changeUserEmail = async (newEmail) => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/user/email`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ email: newEmail })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
return { success: true, message: data.message || 'Email changed successfully! Please check your new email for verification.' };
|
|
} else {
|
|
return { success: false, message: data.error || 'Failed to change email' };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error changing email:', error);
|
|
return { success: false, message: 'Error changing email' };
|
|
}
|
|
};
|
|
|
|
const changeUserPassword = async (currentPassword, newPassword) => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/user/password`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
currentPassword,
|
|
newPassword
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
return { success: true, message: data.message || 'Password changed successfully!' };
|
|
} else {
|
|
return { success: false, message: data.error || 'Failed to change password' };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error changing password:', error);
|
|
return { success: false, message: 'Error changing password' };
|
|
}
|
|
};
|
|
|
|
const deleteUserAccount = async (confirmationText) => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/user/account`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ confirmation: confirmationText })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
localStorage.removeItem('anchorhost_token');
|
|
window.location.href = '/';
|
|
return { success: true, message: 'Account deleted successfully!' };
|
|
} else {
|
|
return { success: false, message: data.error || 'Failed to delete account' };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting account:', error);
|
|
return { success: false, message: 'Error deleting account' };
|
|
}
|
|
};
|
|
|
|
const exportUserData = async () => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/user/export-data`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `anchorhost-data-${new Date().toISOString().split('T')[0]}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} else {
|
|
showAlert('Export Failed', data.error || 'Unknown error', 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Export Error', 'An unexpected error occurred while exporting your data.', 'error');
|
|
}
|
|
};
|
|
|
|
const loadUserProfile = useCallback(async () => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
|
|
if (!token) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Authentication Error",
|
|
message: "No authentication token found. Please log in again.",
|
|
type: "error"
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log('Loading user profile...');
|
|
|
|
const response = await fetch(`${API_BASE_URL}/api/user/profile`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.user) {
|
|
// IMPORTANT: Update userData with email_verified
|
|
setUserData(prev => ({
|
|
...prev,
|
|
...data.user,
|
|
email_verified: data.user.email_verified || false
|
|
}));
|
|
|
|
setPersonalInfo({
|
|
firstName: data.user.first_name || '',
|
|
lastName: data.user.last_name || '',
|
|
address: data.user.address || '',
|
|
city: data.user.city || '',
|
|
country: data.user.country || '',
|
|
postalCode: data.user.postal_code || '',
|
|
phone: data.user.phone || ''
|
|
});
|
|
|
|
// Set country dropdown
|
|
if (data.user.country && countryOptions.length > 0) {
|
|
const userCountry = countryOptions.find(c =>
|
|
c.value === data.user.country || c.name === data.user.country
|
|
);
|
|
if (userCountry) {
|
|
setSelectedCountry(userCountry.value);
|
|
}
|
|
}
|
|
|
|
// Set phone number
|
|
if (data.user.phone) {
|
|
setPhoneNumber(data.user.phone);
|
|
}
|
|
|
|
console.log('✅ Profile loaded successfully:', data.user);
|
|
console.log('📧 Email verified status:', data.user.email_verified);
|
|
} else {
|
|
console.error('Failed to load user profile:', data.error);
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Failed to Load Profile",
|
|
message: data.error || 'Unknown error',
|
|
type: "error"
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading user profile:', error);
|
|
}
|
|
}, [countryOptions, logout]);
|
|
|
|
// Process metrics for human-readable display
|
|
const processMetrics = useCallback((metrics) => {
|
|
if (!metrics) return { cpu: formatCPU('0m'), memory: formatMemory('0Mi') };
|
|
|
|
const serverRAM = selectedServer ? parseInt(selectedServer.ram) || 1 : 1;
|
|
|
|
// Calculate CPU max value for percentage calculation
|
|
const cpuStr = selectedServer?.cpu || '1';
|
|
let cpuCores = 1;
|
|
if (cpuStr.includes('vCPU')) {
|
|
cpuCores = parseInt(cpuStr.split()[0]) || 1;
|
|
} else if (typeof cpuStr === 'string' && /^\d+$/.test(cpuStr.trim())) {
|
|
cpuCores = parseInt(cpuStr.trim()) || 1;
|
|
} else {
|
|
const match = cpuStr.match(/(\d+)/);
|
|
if (match) cpuCores = parseInt(match[1]) || 1;
|
|
}
|
|
const maxCpuMillicores = cpuCores * 1000;
|
|
|
|
// Format CPU with correct percentage
|
|
const cpuFormatted = formatCPU(metrics.cpu);
|
|
// Recalculate percentage based on server's actual CPU allocation
|
|
const cpuPercentage = metrics.cpu_percentage !== undefined
|
|
? metrics.cpu_percentage
|
|
: (cpuFormatted.value / maxCpuMillicores) * 100;
|
|
|
|
return {
|
|
cpu: {
|
|
...cpuFormatted,
|
|
percentage: Math.min(cpuPercentage, 100)
|
|
},
|
|
memory: formatMemory(metrics.memory, serverRAM),
|
|
cpu_percentage: Math.min(cpuPercentage, 100),
|
|
memory_percentage: metrics.memory_percentage || 0,
|
|
timestamp: metrics.timestamp || new Date().toISOString()
|
|
};
|
|
}, [selectedServer]);
|
|
|
|
// WebSocket message handler
|
|
const handleWebSocketMessage = useCallback((data) => {
|
|
console.log('Dashboard received message:', data.type, data);
|
|
|
|
// Forward game console messages
|
|
const gameConsoleMessages = [
|
|
'game_console_result',
|
|
'game_console_output'
|
|
];
|
|
|
|
if (gameConsoleMessages.includes(data.type) && window.__gameConsoleHandler) {
|
|
window.__gameConsoleHandler(data);
|
|
return;
|
|
}
|
|
|
|
// First, let the file manager handle file-related messages
|
|
// For upload_error, also forward if it's a file-related message (even if serverId doesn't match exactly)
|
|
const shouldForwardToFileManager = fileManagerRef.current && (
|
|
String(data.serverId) === String(selectedServer?.id) ||
|
|
(data.type === 'upload_error' && data.filePath) // Forward upload_error even if serverId mismatch
|
|
);
|
|
|
|
if (shouldForwardToFileManager) {
|
|
const fileManagerHandlers = [
|
|
'files_list', 'file_content', 'file_written', 'file_uploaded',
|
|
'file_downloaded', 'directory_created', 'path_deleted',
|
|
'upload_started', 'upload_chunk_received', 'upload_error',
|
|
'path_copied', 'path_moved', 'zip_packed', 'zip_unpacked', 'zip_error'
|
|
];
|
|
|
|
if (fileManagerHandlers.includes(data.type)) {
|
|
fileManagerRef.current.handleWebSocketMessage(data);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle command results for file listing
|
|
if (data.type === 'command_result' && data.serverId === selectedServer?.id) {
|
|
// Let file manager handle ls command results when in files tab
|
|
if (fileManagerRef.current && activeServerDetailTab === 'files') {
|
|
fileManagerRef.current.handleWebSocketMessage(data);
|
|
}
|
|
|
|
// Handle terminal output
|
|
if (isTerminalConnected) {
|
|
setTerminalOutput(prev => prev + `$ ${data.command}\n${data.result}\n`);
|
|
}
|
|
|
|
// Return early to prevent falling through to default case
|
|
return;
|
|
}
|
|
|
|
// Handle domain-related messages - forward to DomainManager
|
|
const domainMessageTypes = [
|
|
'domains_list',
|
|
'custom_domains_list',
|
|
'domain_created',
|
|
'domain_deleted',
|
|
'domain_linked',
|
|
'domain_unlinked',
|
|
'custom_domain_verified',
|
|
'custom_domain_created',
|
|
'custom_domain_deleted'
|
|
];
|
|
|
|
if (domainMessageTypes.includes(data.type) && window.__domainManagerHandler) {
|
|
window.__domainManagerHandler(data);
|
|
return;
|
|
}
|
|
|
|
// Handle other message types
|
|
switch (data.type) {
|
|
case 'email_verified':
|
|
// Update user state after verification
|
|
setUserData(prev => ({
|
|
...prev,
|
|
email_verified: true
|
|
}));
|
|
|
|
// Show success message
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Email Verified! 🎉",
|
|
message: data.message || "Your email has been verified successfully!",
|
|
type: "success"
|
|
});
|
|
|
|
break;
|
|
|
|
case 'authenticated':
|
|
setUserCredits(data.user?.credits || 0);
|
|
// IMPORTANT: Update email_verified during authentication
|
|
setUserData(prev => ({
|
|
...prev,
|
|
...data.user,
|
|
email_verified: data.user?.email_verified || false
|
|
}));
|
|
sendMessageRef.current?.({
|
|
type: 'get_servers'
|
|
});
|
|
// Request server creation progress if we have an active creation
|
|
if (serverCreationProgress.visible) {
|
|
sendMessageRef.current?.({
|
|
type: 'get_server_creation_progress'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'servers_list':
|
|
setServers(data.servers || []);
|
|
// Update selectedServer if it's currently selected to reflect port changes
|
|
if (selectedServer && data.servers) {
|
|
const updatedServer = data.servers.find(s => s.id === selectedServer.id);
|
|
if (updatedServer) {
|
|
setSelectedServer(updatedServer);
|
|
}
|
|
}
|
|
setIsLoading(false);
|
|
break;
|
|
|
|
case 'server_created':
|
|
// Hide progress notification when server creation completes
|
|
setServerCreationProgress(prev => ({ ...prev, visible: false }));
|
|
|
|
if (data.server && data.server.id) {
|
|
setServers(prev => [...prev, data.server]);
|
|
setIsCreatingServer(false);
|
|
} else if (data.serverId) {
|
|
sendMessageRef.current?.({
|
|
type: 'get_servers'
|
|
});
|
|
setIsCreatingServer(false);
|
|
}
|
|
|
|
if (data.credits !== undefined) {
|
|
setUserCredits(data.credits);
|
|
}
|
|
|
|
// Redirect to My Servers tab after successful server creation
|
|
setActiveTab('servers');
|
|
break;
|
|
|
|
case 'server_updated':
|
|
if (data.server && data.server.id) {
|
|
setServers(prev => prev.map(s =>
|
|
s.id === data.server.id ? { ...s, ...data.server } : s
|
|
));
|
|
if (selectedServer && selectedServer.id === data.server.id) {
|
|
setSelectedServer(prev => ({ ...prev, ...data.server }));
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'sftp_generated':
|
|
if (data.serverId === selectedServer?.id) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "SFTP Provisioned! 🚀",
|
|
message: "SFTP credentials have been generated. You can now connect via any SFTP client.",
|
|
type: "success"
|
|
});
|
|
// Refresh servers list to get new credentials
|
|
sendMessageRef.current?.({ type: 'get_servers' });
|
|
}
|
|
break;
|
|
|
|
case 'server_version_changed':
|
|
if (data.serverId && data.version) {
|
|
setServers(prev => prev.map(s =>
|
|
s.id === data.serverId ? { ...s, version: data.version } : s
|
|
));
|
|
if (selectedServer && selectedServer.id === data.serverId) {
|
|
setSelectedServer(prev => ({ ...prev, version: data.version }));
|
|
}
|
|
setIsEditingVersion(false);
|
|
setNewVersion('');
|
|
// Show success message
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Version Changed! ✅",
|
|
message: `Server version has been updated to ${data.version}. Billing date remains unchanged.`,
|
|
type: "success"
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'minecraft_settings':
|
|
if (data.serverId === selectedServer?.id) {
|
|
setMcSettings(data.settings);
|
|
setIsMcSettingsLoading(false);
|
|
}
|
|
break;
|
|
|
|
case 'minecraft_settings_updated':
|
|
if (data.serverId === selectedServer?.id) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Settings Updated!",
|
|
message: "Quick settings updated. Restart server to apply.",
|
|
type: "success"
|
|
});
|
|
setIsMcSettingsLoading(false);
|
|
}
|
|
break;
|
|
case 'server_deleted':
|
|
if (data.serverId) {
|
|
setServers(prev => prev.filter(s => s.id !== data.serverId));
|
|
if (selectedServer && selectedServer.id === data.serverId) {
|
|
setSelectedServer(null);
|
|
setServerLogs('');
|
|
disconnectTerminal();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'server_logs':
|
|
setServerLogs(data.logs || '');
|
|
break;
|
|
|
|
case 'server_metrics':
|
|
if (data.serverId === selectedServer?.id && data.metrics) {
|
|
const processedMetrics = processMetrics(data.metrics);
|
|
setServerMetrics(processedMetrics);
|
|
|
|
const now = new Date();
|
|
const timeLabel = now.toLocaleTimeString();
|
|
|
|
setMetricsHistory(prev => {
|
|
const newCpuHistory = [...prev.cpu.slice(-59), {
|
|
value: processedMetrics.cpu.value,
|
|
time: timeLabel,
|
|
percentage: processedMetrics.cpu_percentage || processedMetrics.cpu.percentage
|
|
}];
|
|
|
|
const newMemoryHistory = [...prev.memory.slice(-59), {
|
|
value: processedMetrics.memory.value,
|
|
time: timeLabel,
|
|
percentage: processedMetrics.memory_percentage || processedMetrics.memory.percentage
|
|
}];
|
|
|
|
return {
|
|
cpu: newCpuHistory,
|
|
memory: newMemoryHistory
|
|
};
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'credits_balance':
|
|
setUserCredits(data.credits || 0);
|
|
break;
|
|
|
|
case 'credits_added':
|
|
setUserCredits(data.new_balance || userCredits);
|
|
break;
|
|
case 'demo_eligibility':
|
|
// Demo eligibility is handled in ServerCreationForm
|
|
break;
|
|
case 'demo_converted':
|
|
setUserCredits(data.credits || userCredits);
|
|
// Refresh servers list
|
|
sendMessageRef.current?.({ type: 'get_servers' });
|
|
// Show success message in custom themed popup
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Conversion Successful',
|
|
message: `Demo server converted successfully! Charged ${data.credits_charged} credits. New balance: ${data.credits} credits.`,
|
|
type: 'success'
|
|
});
|
|
break;
|
|
|
|
case 'server_creation_progress':
|
|
// Hide progress if no active creation found (after reconnection check)
|
|
if (data.phase === 'Unknown' && data.progress === 0 && data.message === 'No active server creation found') {
|
|
setServerCreationProgress(prev => ({ ...prev, visible: false }));
|
|
break;
|
|
}
|
|
|
|
setServerCreationProgress({
|
|
visible: true,
|
|
phase: data.phase || '',
|
|
progress: data.progress || 0,
|
|
message: data.message || 'Creating server...'
|
|
});
|
|
// Hide notification when progress reaches 100%
|
|
if (data.progress >= 100) {
|
|
setTimeout(() => {
|
|
setServerCreationProgress(prev => ({ ...prev, visible: false }));
|
|
}, 2000);
|
|
}
|
|
break;
|
|
|
|
case 'port_exposed':
|
|
// Refresh servers list to get updated ports
|
|
sendMessageRef.current?.({
|
|
type: 'get_servers'
|
|
});
|
|
// Show success message if available
|
|
if (data.message) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Port Exposed! ✅",
|
|
message: data.message,
|
|
type: "success"
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'port_removed':
|
|
// Refresh servers list to get updated ports
|
|
sendMessageRef.current?.({
|
|
type: 'get_servers'
|
|
});
|
|
// Show success message if available
|
|
if (data.message) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Port Removed! ✅",
|
|
message: data.message,
|
|
type: "success"
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'auth_error':
|
|
console.error('Authentication error:', data.error);
|
|
logout();
|
|
break;
|
|
|
|
case 'error':
|
|
console.error('WebSocket error:', data.error);
|
|
if (data.error === 'Not authenticated') {
|
|
sendMessageRef.current?.({
|
|
type: 'authenticate',
|
|
token: token
|
|
});
|
|
} else if (data.error && (data.error.toLowerCase().includes('convert demo') || data.error.toLowerCase().includes('convert_demo') || (data.error.toLowerCase().includes('demo') && data.error.toLowerCase().includes('paid')))) {
|
|
// Show custom themed popup for demo conversion errors
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Conversion Failed',
|
|
message: data.error || 'Failed to convert demo server to paid',
|
|
type: 'error'
|
|
});
|
|
} else if (data.error && data.error !== "0" && data.error !== 0) {
|
|
// Show popup for other errors
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Error',
|
|
message: data.error || 'An error occurred',
|
|
type: 'error'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case '2fa_setup_details':
|
|
setTwoFactorData(data);
|
|
setShow2FASetup(true);
|
|
break;
|
|
case '2fa_enabled':
|
|
setShow2FASetup(false);
|
|
setAlertModal({ open: true, title: 'Success', message: '2FA enabled successfully!', type: 'success' });
|
|
loadUserProfile();
|
|
break;
|
|
case '2fa_disabled':
|
|
setAlertModal({ open: true, title: 'Success', message: '2FA disabled successfully!', type: 'success' });
|
|
loadUserProfile();
|
|
break;
|
|
default:
|
|
console.log('Unhandled message type:', data.type);
|
|
}
|
|
}, [selectedServer, activeServerDetailTab, isTerminalConnected, processMetrics, token, loadUserProfile, serverCreationProgress.visible, userCredits, logout]);
|
|
const { ws: dashboardWs, sendMessage: sendDashboardMessage, connectionStatus: dashboardStatus, lastJsonMessage: lastMessage } = useWebSocket(
|
|
`${WS_URL}?token=${token}`,
|
|
handleWebSocketMessage
|
|
);
|
|
|
|
// Update ref when sendDashboardMessage changes to avoid circular dependency
|
|
useEffect(() => {
|
|
sendMessageRef.current = sendDashboardMessage;
|
|
}, [sendDashboardMessage]);
|
|
|
|
// Request server creation progress after reconnection if creation is in progress
|
|
useEffect(() => {
|
|
if (dashboardStatus === 'connected' && serverCreationProgress.visible && sendDashboardMessage) {
|
|
// Small delay to ensure authentication is complete
|
|
const timeoutId = setTimeout(() => {
|
|
sendDashboardMessage({
|
|
type: 'get_server_creation_progress'
|
|
});
|
|
}, 500);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [dashboardStatus, serverCreationProgress.visible, sendDashboardMessage]);
|
|
|
|
// Request metrics when server is selected and periodically update
|
|
useEffect(() => {
|
|
if (!selectedServer || !sendDashboardMessage || dashboardStatus !== 'connected') {
|
|
// Reset metrics when no server selected
|
|
setServerMetrics({ cpu: '0m', memory: '0Mi', cpu_percentage: 0, memory_percentage: 0 });
|
|
setMetricsHistory({ cpu: [], memory: [] });
|
|
return;
|
|
}
|
|
|
|
// Request metrics immediately when server is selected
|
|
const requestMetrics = () => {
|
|
sendDashboardMessage({
|
|
type: 'get_server_metrics',
|
|
serverId: selectedServer.id
|
|
});
|
|
};
|
|
|
|
// Request metrics immediately
|
|
requestMetrics();
|
|
|
|
// Set up interval to request metrics every 2 seconds
|
|
const metricsInterval = setInterval(requestMetrics, 2000);
|
|
|
|
return () => {
|
|
clearInterval(metricsInterval);
|
|
};
|
|
}, [selectedServer, sendDashboardMessage, dashboardStatus]);
|
|
|
|
// Request Quick settings when tab is active
|
|
useEffect(() => {
|
|
if (activeServerDetailTab === 'minecraft-settings' && selectedServer && dashboardStatus === 'connected' && !mcSettings) {
|
|
setIsMcSettingsLoading(true);
|
|
sendDashboardMessage({
|
|
type: 'get_minecraft_settings',
|
|
serverId: selectedServer.id
|
|
});
|
|
}
|
|
}, [activeServerDetailTab, selectedServer, dashboardStatus, sendDashboardMessage, mcSettings]);
|
|
|
|
// Clear Quick settings when server changes
|
|
useEffect(() => {
|
|
setMcSettings(null);
|
|
}, [selectedServer]);
|
|
|
|
const terminalRef = useRef(null);
|
|
|
|
const connectTerminal = () => {
|
|
if (selectedServer) {
|
|
setIsTerminalConnected(true);
|
|
setTerminalOutput("Connected to server terminal. Type commands below.");
|
|
}
|
|
};
|
|
|
|
const sendTerminalCommand = (command) => {
|
|
if (selectedServer && dashboardStatus === 'connected') {
|
|
sendDashboardMessage({
|
|
type: 'exec_command',
|
|
serverId: selectedServer.id,
|
|
command: command
|
|
});
|
|
setTerminalInput('');
|
|
}
|
|
};
|
|
|
|
const handleTerminalKeyDown = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
sendTerminalCommand(terminalInput);
|
|
}
|
|
};
|
|
|
|
const disconnectTerminal = () => {
|
|
setIsTerminalConnected(false);
|
|
setTerminalOutput('');
|
|
};
|
|
|
|
const handleCreateServer = async (serverConfig) => {
|
|
setIsCreatingServer(true);
|
|
sendDashboardMessage({
|
|
type: 'create_server',
|
|
name: serverConfig.name,
|
|
serverType: serverConfig.type,
|
|
image: serverConfig.image,
|
|
version: serverConfig.version,
|
|
cpu: serverConfig.cpu,
|
|
ram: serverConfig.ram,
|
|
storage: serverConfig.storage,
|
|
ports: serverConfig.ports,
|
|
is_demo: serverConfig.is_demo || false
|
|
});
|
|
};
|
|
|
|
const handleDeleteServer = async (serverId) => {
|
|
setDeleteServerModal({ open: true, serverId: serverId });
|
|
};
|
|
|
|
const confirmDeleteServer = async () => {
|
|
if (deleteServerModal.serverId) {
|
|
sendDashboardMessage({
|
|
type: 'delete_server',
|
|
serverId: deleteServerModal.serverId
|
|
});
|
|
setDeleteServerModal({ open: false, serverId: null });
|
|
}
|
|
};
|
|
|
|
// Calculate server cost based on specs (matching backend logic)
|
|
const calculateServerCost = (server) => {
|
|
// Get CPU cores
|
|
let cpuCores = 1;
|
|
if (server.cpu) {
|
|
if (typeof server.cpu === 'string' && server.cpu.includes('vCPU')) {
|
|
cpuCores = parseInt(server.cpu.split(' ')[0]);
|
|
} else {
|
|
cpuCores = parseInt(server.cpu);
|
|
}
|
|
}
|
|
|
|
// Get RAM GB
|
|
let ramGb = 1;
|
|
if (server.ram) {
|
|
if (typeof server.ram === 'string' && server.ram.includes('GB')) {
|
|
ramGb = parseInt(server.ram.replace('GB', ''));
|
|
} else {
|
|
ramGb = parseInt(server.ram);
|
|
}
|
|
}
|
|
|
|
// Get Storage GB
|
|
let storageGb = 10;
|
|
if (server.storage) {
|
|
if (typeof server.storage === 'string' && server.storage.includes('GB')) {
|
|
storageGb = parseInt(server.storage.replace('GB', ''));
|
|
}
|
|
else {
|
|
storageGb = parseInt(server.storage);
|
|
}
|
|
}
|
|
|
|
// Type multipliers
|
|
const typeMultiplier = {
|
|
'vps': pricing.multiplier_vps || 1.0,
|
|
'game': pricing.multiplier_game || 0.8,
|
|
'website': pricing.multiplier_website || 1.5,
|
|
'database': pricing.multiplier_database || 1.3
|
|
}[server.type] || 1.0;
|
|
|
|
// Calculate costs (matching backend: 1 vCPU = 35 credits, 1GB RAM = 35 credits, 1GB Storage = 5 credits)
|
|
const cpuCost = cpuCores * (pricing.price_cpu || 35);
|
|
const ramCost = ramGb * (pricing.price_ram || 35);
|
|
const storageCost = storageGb * (pricing.price_storage || 5);
|
|
|
|
const baseCost = 0;
|
|
|
|
// Total cost
|
|
const totalCost = Math.round((baseCost + cpuCost + ramCost + storageCost) * typeMultiplier);
|
|
return totalCost;
|
|
};
|
|
|
|
const handleConvertDemoToPaid = async (serverId) => {
|
|
// Find the server in the servers list
|
|
const server = servers.find(s => s.id === serverId);
|
|
if (!server) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Error',
|
|
message: 'Server not found',
|
|
type: 'error'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Calculate cost
|
|
const cost = calculateServerCost(server);
|
|
|
|
// Open the custom modal
|
|
setConvertDemoModal({
|
|
open: true,
|
|
server: server,
|
|
cost: cost
|
|
});
|
|
};
|
|
|
|
const confirmConvertDemoToPaid = () => {
|
|
if (!convertDemoModal.server) return;
|
|
|
|
sendDashboardMessage({
|
|
type: 'convert_demo_to_paid',
|
|
serverId: convertDemoModal.server.id
|
|
});
|
|
|
|
// Close modal
|
|
setConvertDemoModal({ open: false, server: null, cost: 0 });
|
|
};
|
|
|
|
const handleServerAction = async (serverId, action) => {
|
|
if (action === 'restart') {
|
|
sendDashboardMessage({
|
|
type: 'restart_server',
|
|
serverId: serverId
|
|
});
|
|
} else {
|
|
sendDashboardMessage({
|
|
type: action + '_server',
|
|
serverId: serverId
|
|
});
|
|
}
|
|
};
|
|
|
|
const fetchServerLogs = async (serverId) => {
|
|
sendDashboardMessage({
|
|
type: 'get_server_logs',
|
|
serverId: serverId
|
|
});
|
|
};
|
|
|
|
const handleExposePort = async (serverId, internalPort, protocol = 'TCP') => {
|
|
sendDashboardMessage({
|
|
type: 'expose_port',
|
|
serverId: serverId,
|
|
internalPort: parseInt(internalPort),
|
|
protocol: protocol
|
|
});
|
|
};
|
|
|
|
const handleRemovePort = async (serverId, internalPort, protocol = 'TCP') => {
|
|
sendDashboardMessage({
|
|
type: 'remove_port',
|
|
serverId: serverId,
|
|
internalPort: parseInt(internalPort),
|
|
protocol: protocol
|
|
});
|
|
};
|
|
|
|
// Helper function to parse CPU value (e.g., "2 vCPU" -> 2)
|
|
const parseCpuValue = (cpuStr) => {
|
|
if (!cpuStr) return 1;
|
|
const match = cpuStr.match(/(\d+)/);
|
|
return match ? parseInt(match[1]) : 1;
|
|
};
|
|
|
|
// Helper function to parse RAM value (e.g., "4GB" -> 4)
|
|
const parseRamValue = (ramStr) => {
|
|
if (!ramStr) return 1;
|
|
const match = ramStr.match(/(\d+)\s*GB/i);
|
|
return match ? parseInt(match[1]) : 1;
|
|
};
|
|
|
|
// Helper function to parse Storage value (e.g., "50GB" -> 50)
|
|
const parseStorageValue = (storageStr) => {
|
|
if (!storageStr) return 10;
|
|
const match = storageStr.match(/(\d+)\s*GB/i);
|
|
return match ? parseInt(match[1]) : 10;
|
|
};
|
|
|
|
// Initialize resource values when server is selected
|
|
useEffect(() => {
|
|
if (selectedServer) {
|
|
setResourceValues({
|
|
cpu: parseCpuValue(selectedServer.cpu),
|
|
ram: parseRamValue(selectedServer.ram),
|
|
storage: parseStorageValue(selectedServer.storage)
|
|
});
|
|
}
|
|
}, [selectedServer]);
|
|
|
|
// Function to handle adjusting server parameters
|
|
const handleAdjustServerParameters = async () => {
|
|
if (!selectedServer) return;
|
|
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/servers/${selectedServer.id}/adjust-parameters`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
cpu: `${resourceValues.cpu} vCPU`,
|
|
ram: `${resourceValues.ram}GB`,
|
|
storage: `${resourceValues.storage}GB`
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
let message = 'Server parameters adjusted successfully.';
|
|
if (data.is_upgrade && data.credits_charged > 0) {
|
|
message = `Server upgraded successfully. Charged ${data.credits_charged} credits (difference between new plan: ${data.new_monthly_cost} credits/month and current plan: ${data.current_monthly_cost} credits/month).`;
|
|
} else if (!data.is_upgrade) {
|
|
message = `Server downgraded successfully. No credits were charged. Your new monthly cost is ${data.new_monthly_cost} credits (reduced from ${data.current_monthly_cost} credits).`;
|
|
} else {
|
|
message = `Server parameters adjusted successfully. No additional charge.`;
|
|
}
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Success',
|
|
message: message,
|
|
type: 'success'
|
|
});
|
|
setIsEditingResources(false);
|
|
// Refresh servers to get updated values
|
|
refreshServers();
|
|
// Refresh credits
|
|
refreshCredits();
|
|
} else {
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Error',
|
|
message: data.error || 'Failed to adjust server parameters',
|
|
type: 'error'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Error',
|
|
message: `Failed to adjust server parameters: ${error.message}`,
|
|
type: 'error'
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdateMcSettings = (newSettings) => {
|
|
if (!selectedServer) return;
|
|
setIsMcSettingsLoading(true);
|
|
sendDashboardMessage({
|
|
type: 'update_minecraft_settings',
|
|
serverId: selectedServer.id,
|
|
settings: newSettings
|
|
});
|
|
};
|
|
|
|
const refreshCredits = () => {
|
|
sendDashboardMessage({
|
|
type: 'get_credits'
|
|
});
|
|
};
|
|
|
|
const handleAddCredits = (amount) => {
|
|
sendDashboardMessage({
|
|
type: 'add_credits',
|
|
amount: amount
|
|
});
|
|
};
|
|
|
|
const refreshServers = () => {
|
|
setIsLoading(true);
|
|
sendDashboardMessage({
|
|
type: 'get_servers'
|
|
});
|
|
};
|
|
|
|
// Auto-refresh servers when My Servers tab is active
|
|
useEffect(() => {
|
|
if (activeTab === 'servers' && sendDashboardMessage) {
|
|
// Refresh immediately when tab becomes active
|
|
sendDashboardMessage({
|
|
type: 'get_servers'
|
|
});
|
|
|
|
// Set up interval to refresh every 10 seconds
|
|
const interval = setInterval(() => {
|
|
sendDashboardMessage({
|
|
type: 'get_servers'
|
|
});
|
|
}, 10000);
|
|
|
|
// Cleanup interval when tab changes or component unmounts
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [activeTab, sendDashboardMessage]);
|
|
|
|
const handleTabChange = (tab) => {
|
|
if (tab === 'admin') {
|
|
navigate('/admin');
|
|
return;
|
|
}
|
|
setActiveTab(tab);
|
|
setIsMobileSidebarOpen(false);
|
|
};
|
|
|
|
const settingsInputClass =
|
|
'w-full rounded-xl px-4 py-3 text-sm border border-dash-border bg-brightBlack/60 text-dash-text placeholder:text-dash-textMuted/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/35 transition-colors';
|
|
|
|
// Calculate total resources used
|
|
const totalResources = servers.reduce((acc, server) => {
|
|
const cpu = parseInt(server.cpu) || 0;
|
|
const ram = parseInt(server.ram) || 0;
|
|
const storage = parseInt(server.storage) || 0;
|
|
return {
|
|
cpu: acc.cpu + cpu,
|
|
ram: acc.ram + ram,
|
|
storage: acc.storage + storage
|
|
};
|
|
}, { cpu: 0, ram: 0, storage: 0 });
|
|
|
|
// Check if port is default (cannot be removed)
|
|
const isDefaultPort = (server, port) => {
|
|
if (!server || !port) return false;
|
|
|
|
const defaultPorts = {
|
|
'website': [{ internal: 80, protocol: 'TCP' }],
|
|
'game': {
|
|
'minecraft': [{ internal: 25565, protocol: 'TCP' }],
|
|
'fivem': [{ internal: 30120, protocol: 'TCP' }],
|
|
'cs2': [{ internal: 27015, protocol: 'TCP' }],
|
|
'rust': [{ internal: 28015, protocol: 'TCP' }],
|
|
'ark': [{ internal: 7777, protocol: 'TCP' }],
|
|
'valheim': [{ internal: 2456, protocol: 'TCP' }]
|
|
},
|
|
'vps': {
|
|
'windows': [{ internal: 3389, protocol: 'TCP' }],
|
|
'default': [{ internal: 22, protocol: 'TCP' }]
|
|
},
|
|
'database': {
|
|
'mysql': [{ internal: 3306, protocol: 'TCP' }],
|
|
'postgresql': [{ internal: 5432, protocol: 'TCP' }],
|
|
'mongodb': [{ internal: 27017, protocol: 'TCP' }],
|
|
'redis': [{ internal: 6379, protocol: 'TCP' }]
|
|
}
|
|
};
|
|
|
|
if (server.type === 'website') {
|
|
return defaultPorts.website.some(p => p.internal === port.internal && p.protocol === port.protocol);
|
|
} else if (server.type === 'game') {
|
|
const gamePorts = defaultPorts.game[server.image] || [];
|
|
return gamePorts.some(p => p.internal === port.internal && p.protocol === port.protocol);
|
|
} else if (server.type === 'vps') {
|
|
const vpsPorts = defaultPorts.vps[server.image] || defaultPorts.vps.default;
|
|
return vpsPorts.some(p => p.internal === port.internal && p.protocol === port.protocol);
|
|
} else if (server.type === 'database') {
|
|
const dbPorts = defaultPorts.database[server.image] || [];
|
|
return dbPorts.some(p => p.internal === port.internal && p.protocol === port.protocol);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
// Get port tooltip information for common ports
|
|
const getPortTooltip = (internalPort, externalPort) => {
|
|
const portInfo = {
|
|
22: {
|
|
name: 'SSH (Secure Shell)',
|
|
description: 'Used for secure remote access to servers',
|
|
connection: `Connect using: ssh root@${selectedServer?.ip || 'SERVER_IP'} -p ${externalPort}`
|
|
},
|
|
80: {
|
|
name: 'HTTP',
|
|
description: 'Web server port for unencrypted HTTP traffic',
|
|
connection: `Access at: http://${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
443: {
|
|
name: 'HTTPS',
|
|
description: 'Web server port for encrypted HTTPS traffic',
|
|
connection: `Access at: https://${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
3306: {
|
|
name: 'MySQL',
|
|
description: 'MySQL database server port',
|
|
connection: `Connect using: mysql -h ${selectedServer?.ip || 'SERVER_IP'} -P ${externalPort} -u USERNAME -p`
|
|
},
|
|
5432: {
|
|
name: 'PostgreSQL',
|
|
description: 'PostgreSQL database server port',
|
|
connection: `Connect using: psql -h ${selectedServer?.ip || 'SERVER_IP'} -p ${externalPort} -U USERNAME`
|
|
},
|
|
27017: {
|
|
name: 'MongoDB',
|
|
description: 'MongoDB database server port',
|
|
connection: `Connect using: mongosh mongodb://${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
6379: {
|
|
name: 'Redis',
|
|
description: 'Redis in-memory data store port',
|
|
connection: `Connect using: redis-cli -h ${selectedServer?.ip || 'SERVER_IP'} -p ${externalPort}`
|
|
},
|
|
3389: {
|
|
name: 'RDP (Remote Desktop)',
|
|
description: 'Windows Remote Desktop Protocol port',
|
|
connection: `Connect using Remote Desktop Client to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
25565: {
|
|
name: 'Minecraft',
|
|
description: 'Minecraft server port',
|
|
connection: `Connect in-game to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
30120: {
|
|
name: 'FiveM',
|
|
description: 'FiveM game server port',
|
|
connection: `Connect in-game to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
27015: {
|
|
name: 'CS2/Steam',
|
|
description: 'Counter-Strike 2 / Steam game server port',
|
|
connection: `Connect in-game to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
28015: {
|
|
name: 'Rust',
|
|
description: 'Rust game server port',
|
|
connection: `Connect in-game to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
7777: {
|
|
name: 'ARK',
|
|
description: 'ARK: Survival Evolved game server port',
|
|
connection: `Connect in-game to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
},
|
|
2456: {
|
|
name: 'Valheim',
|
|
description: 'Valheim game server port',
|
|
connection: `Connect in-game to ${selectedServer?.ip || 'SERVER_IP'}:${externalPort}`
|
|
}
|
|
};
|
|
|
|
return portInfo[internalPort] || null;
|
|
};
|
|
|
|
const getPortServiceName = (server, port, isDefault) => {
|
|
if (port.name) return port.name;
|
|
const tooltip = getPortTooltip(parseInt(port.internal, 10), port.external);
|
|
if (tooltip) return tooltip.name;
|
|
if (isDefault) return 'Main Service';
|
|
return 'Custom Service';
|
|
};
|
|
|
|
const getNetworkConnectionHint = (server) => {
|
|
switch (server?.type) {
|
|
case 'game':
|
|
return 'Paste the public address into your game client\'s Direct Connect or Add Server field.';
|
|
case 'vps':
|
|
return 'Use SSH, RDP, or your remote tool with the public IP and external port below.';
|
|
case 'database':
|
|
return 'Point your database client at the public IP and external port — not the internal one.';
|
|
case 'website':
|
|
return 'Open the public address in your browser. HTTP services are mapped automatically.';
|
|
default:
|
|
return 'Copy the full IP:port address and use it in your application or client.';
|
|
}
|
|
};
|
|
|
|
const submitNetworkExposePort = () => {
|
|
if (!selectedServer || !newPortInternal.trim()) return;
|
|
const val = parseInt(newPortInternal, 10);
|
|
if (val >= 1 && val <= 65535) {
|
|
handleExposePort(selectedServer.id, newPortInternal, newPortProtocol);
|
|
setNewPortInternal('');
|
|
} else {
|
|
setAlertModal({ open: true, title: 'Invalid Port', message: 'Port must be between 1 and 65535', type: 'error' });
|
|
}
|
|
};
|
|
|
|
// Port Tooltip Component with fixed positioning to avoid overflow clipping
|
|
const PortTooltip = ({ portTooltip }) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
const iconRef = useRef(null);
|
|
const hideTimeoutRef = useRef(null);
|
|
|
|
const handleMouseEnter = () => {
|
|
// Clear any pending hide timeout
|
|
if (hideTimeoutRef.current) {
|
|
clearTimeout(hideTimeoutRef.current);
|
|
hideTimeoutRef.current = null;
|
|
}
|
|
if (iconRef.current) {
|
|
const rect = iconRef.current.getBoundingClientRect();
|
|
// Position tooltip above the icon, centered horizontally
|
|
// Position at the top of the icon, then translate up by 100% of tooltip height plus 8px gap
|
|
setPosition({
|
|
top: rect.top,
|
|
left: rect.left + rect.width / 2 // Center horizontally on icon
|
|
});
|
|
setIsVisible(true);
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
// Add a small delay before hiding to allow smooth transition to tooltip
|
|
hideTimeoutRef.current = setTimeout(() => {
|
|
setIsVisible(false);
|
|
hideTimeoutRef.current = null;
|
|
}, 100);
|
|
};
|
|
|
|
const handleTooltipMouseEnter = () => {
|
|
// Clear any pending hide timeout when mouse enters tooltip
|
|
if (hideTimeoutRef.current) {
|
|
clearTimeout(hideTimeoutRef.current);
|
|
hideTimeoutRef.current = null;
|
|
}
|
|
setIsVisible(true);
|
|
};
|
|
|
|
const handleTooltipMouseLeave = () => {
|
|
setIsVisible(false);
|
|
};
|
|
|
|
// Cleanup timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (hideTimeoutRef.current) {
|
|
clearTimeout(hideTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const tooltipContent = isVisible ? (
|
|
<div
|
|
className="ah-tooltip ah-tooltip-detail fixed w-72 max-w-[calc(100vw-2rem)] p-4 text-sm z-[9999]"
|
|
style={{
|
|
top: `${position.top}px`,
|
|
left: `${position.left}px`,
|
|
transform: 'translate(-50%, calc(-100% - 8px))'
|
|
}}
|
|
onMouseEnter={handleTooltipMouseEnter}
|
|
onMouseLeave={handleTooltipMouseLeave}
|
|
>
|
|
<div className="ah-tooltip-heading">{portTooltip.name}</div>
|
|
<div className="ah-tooltip-description">{portTooltip.description}</div>
|
|
<div className="ah-tooltip-code">{portTooltip.connection}</div>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={iconRef}
|
|
className="group relative inline-block"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<Info className="w-3 h-3 cursor-help" style={{ color: colors.blue }} />
|
|
</div>
|
|
{tooltipContent && createPortal(tooltipContent, document.body)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const dashboardSidebarItems = useMemo(
|
|
() => [
|
|
{ id: 'servers', label: 'My Servers', icon: Server, color: colors.blue },
|
|
{ id: 'create', label: 'Create Server', icon: Plus, color: colors.green },
|
|
{ id: 'domains', label: 'Domains', icon: Globe, color: colors.cyan },
|
|
{ id: 'store', label: 'Store', icon: ShoppingCart, color: colors.yellow },
|
|
{ id: 'tickets', label: 'Support', icon: MessageCircle, color: colors.magenta },
|
|
{ id: 'settings', label: 'Settings', icon: Settings, color: colors.magenta },
|
|
...(user?.role === 'admin'
|
|
? [{ id: 'admin', label: 'Admin Panel', icon: Shield, color: colors.red }]
|
|
: []),
|
|
],
|
|
[user?.role]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="dashboard-app flex h-screen overflow-hidden bg-dash-surface font-sans selection:bg-primary/30 text-dash-text antialiased"
|
|
>
|
|
<aside
|
|
className={`fixed inset-y-0 left-0 z-50 w-72 transform transition-all duration-300 ease-in-out md:relative md:translate-x-0 border-r border-dash-border bg-dash-surfaceElevated/50 backdrop-blur-xl pt-[env(safe-area-inset-top,0px)] ${
|
|
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
}`}
|
|
>
|
|
<div className="flex flex-col h-full min-h-0">
|
|
<div className="p-6 border-b border-dash-border bg-black/10">
|
|
<div className="flex items-center space-x-3 group cursor-pointer" onClick={() => navigate('/')}>
|
|
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20 transition-all group-hover:scale-110 shadow-lg shadow-primary/5">
|
|
<img src={branding.logoPath} alt="" className="h-7 w-auto shrink-0" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-xl font-bold tracking-tight text-dash-text leading-tight">Dashboard</h1>
|
|
<p className="text-[10px] font-bold text-dash-textMuted/40 uppercase tracking-[0.2em]">Control Center</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 flex flex-col gap-2 border-b border-dash-border bg-black/5 shrink-0">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider px-3 py-2.5 rounded-xl justify-center border border-dash-border bg-black/20">
|
|
{dashboardStatus === 'connected' ? (
|
|
<Wifi className="w-3.5 h-3.5 shrink-0" style={{ color: colors.green }} />
|
|
) : (
|
|
<WifiOff className="w-3.5 h-3.5 shrink-0" style={{ color: colors.red }} />
|
|
)}
|
|
<span style={{ color: dashboardStatus === 'connected' ? colors.green : colors.red }}>
|
|
{dashboardStatus === 'connected' ? 'Live' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
|
|
<UnifiedTooltip content={`${userCredits.toLocaleString()} credits`} placement="bottom">
|
|
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider px-3 py-2.5 rounded-xl w-full justify-center border border-dash-border bg-black/20">
|
|
<Coins className="w-3.5 h-3.5 shrink-0" style={{ color: colors.yellow }} />
|
|
<span style={{ color: colors.brightWhite }} className="truncate min-w-0">
|
|
{calculateCurrencyValue(userCredits)} {getCurrencySymbol(userCredits)}
|
|
</span>
|
|
</div>
|
|
</UnifiedTooltip>
|
|
</div>
|
|
<nav className="flex-1 overflow-y-auto py-6 px-4 space-y-8 min-h-0 custom-scrollbar bg-black/5">
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
{dashboardSidebarItems.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const active = activeTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => handleTabChange(tab.id)}
|
|
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-xl transition-all duration-200 group text-sm font-bold border ${
|
|
active
|
|
? 'bg-primary/10 border-primary/25 text-dash-text shadow-lg shadow-primary/5'
|
|
: 'border-transparent text-dash-textMuted/60 hover:bg-white/5 hover:text-dash-text hover:border-dash-border/10'
|
|
}`}
|
|
>
|
|
<div className={`p-2 rounded-lg transition-all duration-300 ${active ? 'bg-primary/20 scale-110' : 'bg-white/5 group-hover:bg-white/10 group-hover:scale-110'}`}>
|
|
<Icon className={`w-4 h-4 transition-transform ${active ? 'text-primary' : 'opacity-50'} group-hover:rotate-3`} />
|
|
</div>
|
|
<span className="text-left truncate">{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<p className="px-4 text-[10px] font-bold text-dash-textMuted/40 uppercase tracking-[0.2em]">Resources</p>
|
|
<div className="space-y-1">
|
|
<button type="button" onClick={() => navigate('/')} className="w-full flex items-center space-x-3 px-4 py-3 rounded-xl transition-all duration-200 group text-sm font-bold border border-transparent text-dash-textMuted/60 hover:bg-white/5 hover:text-dash-text hover:border-dash-border/10">
|
|
<div className="p-2 rounded-lg bg-white/5 group-hover:bg-white/10 transition-all duration-300 group-hover:scale-110">
|
|
<Home className="w-4 h-4 opacity-50 transition-transform group-hover:rotate-3" />
|
|
</div>
|
|
<span>Home</span>
|
|
</button>
|
|
<button type="button" onClick={() => navigate('/documentation')} className="w-full flex items-center space-x-3 px-4 py-3 rounded-xl transition-all duration-200 group text-sm font-bold border border-transparent text-dash-textMuted/60 hover:bg-white/5 hover:text-dash-text hover:border-dash-border/10">
|
|
<div className="p-2 rounded-lg bg-white/5 group-hover:bg-white/10 transition-all duration-300 group-hover:scale-110">
|
|
<BookOpen className="w-4 h-4 opacity-50 transition-transform group-hover:rotate-3" />
|
|
</div>
|
|
<span>Documentation</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="p-4 border-t border-dash-border bg-black/20 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] shrink-0">
|
|
<div className="flex items-center p-3 rounded-2xl border border-dash-border bg-dash-surfaceElevated/50">
|
|
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/25 flex items-center justify-center font-bold text-sm text-primary shadow-inner">
|
|
{user?.username ? user.username.substring(0, 2).toUpperCase() : '??'}
|
|
</div>
|
|
<div className="ml-3 flex-1 min-w-0">
|
|
<p className="text-sm font-bold truncate text-dash-text">{user?.username}</p>
|
|
<p className="text-[10px] uppercase font-bold tracking-[0.15em] text-dash-textMuted/40">{user?.role || 'Account'}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={logout}
|
|
className="p-2.5 rounded-xl hover:bg-red-500/10 text-dash-textMuted/40 hover:text-red-500 transition-all group"
|
|
title="Logout"
|
|
>
|
|
<LogOut className="w-4 h-4 group-hover:scale-110 group-hover:-translate-x-0.5 transition-transform" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
|
|
|
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
|
<header className="md:hidden flex items-center justify-between p-4 border-b border-dash-border bg-dash-surfaceElevated/80 backdrop-blur-md pt-[calc(1rem+env(safe-area-inset-top,0px))]">
|
|
<div className="flex items-center space-x-3 min-w-0">
|
|
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20 shadow-sm">
|
|
<Server className="w-5 h-5 text-primary" />
|
|
</div>
|
|
<span className="font-bold text-dash-text tracking-tight truncate">Dashboard</span>
|
|
</div>
|
|
<button type="button" onClick={() => setIsMobileSidebarOpen(!isMobileSidebarOpen)} className="p-2.5 rounded-xl text-dash-textMuted hover:bg-white/10 active:scale-95 transition-all">
|
|
<Menu className="w-6 h-6" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar pt-[env(safe-area-inset-top,0px)]">
|
|
<div className="p-4 md:p-8 lg:p-12 max-w-[1600px] mx-auto w-full animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<div className="w-full">
|
|
{activeTab === 'servers' && (
|
|
<div className="w-full space-y-6">
|
|
<DashboardViewShell>
|
|
<PageHeaderBar
|
|
accentColor={colors.blue}
|
|
icon={Server}
|
|
title="My Servers"
|
|
description="Manage and monitor your deployed servers"
|
|
actions={
|
|
<ActionButton variant="secondary" onClick={refreshServers} aria-label="Refresh server list">
|
|
<RefreshCw className="w-4 h-4 shrink-0" aria-hidden />
|
|
<span>Refresh</span>
|
|
</ActionButton>
|
|
}
|
|
/>
|
|
<div className={dashLayout.bodyPadding}>
|
|
{servers.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatTile icon={Cpu} label="Total CPU" value={`${totalResources.cpu} vCPU`} accentColor={colors.blue} />
|
|
<StatTile icon={MemoryStick} label="Total RAM" value={`${totalResources.ram} GB`} accentColor={colors.green} />
|
|
<StatTile icon={HardDrive} label="Total Storage" value={`${totalResources.storage} GB`} accentColor={colors.yellow} />
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<DashboardLoadingSpinner label="Loading servers" />
|
|
) : servers.length === 0 ? (
|
|
<DashboardEmptyState
|
|
icon={Server}
|
|
title="No servers yet"
|
|
description="Create a server to deploy games, sites, or databases on your infrastructure."
|
|
action={
|
|
<ActionButton
|
|
variant="primary"
|
|
onClick={() => setActiveTab('create')}
|
|
className="!px-4 !py-2 !min-h-0 sm:!min-h-0"
|
|
>
|
|
Create your first server
|
|
</ActionButton>
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
{servers.map((server) => (
|
|
<ServerCard
|
|
key={server.id}
|
|
server={server}
|
|
pricing={pricing}
|
|
userRole={user?.role}
|
|
calculateCurrencyValue={calculateCurrencyValue}
|
|
getCurrencySymbol={getCurrencySymbol}
|
|
onConvertDemo={handleConvertDemoToPaid}
|
|
onOpenConsole={(targetServer) => {
|
|
setSelectedServer(targetServer);
|
|
setActiveServerDetailTab('overview');
|
|
fetchServerLogs(targetServer.id);
|
|
}}
|
|
onServerAction={handleServerAction}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DashboardViewShell>
|
|
</div>
|
|
)}
|
|
{activeTab === 'create' && (
|
|
<ServerCreationForm
|
|
onCreateServer={handleCreateServer}
|
|
isCreating={isCreatingServer}
|
|
userCredits={userCredits}
|
|
selectedCurrency={selectedCurrency}
|
|
onCurrencyChange={setSelectedCurrency}
|
|
wsClient={dashboardWs}
|
|
pricing={pricing}
|
|
/>
|
|
|
|
)}
|
|
|
|
{activeTab === 'store' && (
|
|
<div className="w-full space-y-6">
|
|
<StoreSection
|
|
userCredits={userCredits}
|
|
onAddCredits={handleAddCredits}
|
|
onRefreshCredits={refreshCredits}
|
|
selectedCurrency={selectedCurrency}
|
|
onCurrencyChange={setSelectedCurrency}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'domains' && (
|
|
<div className="w-full space-y-6">
|
|
<DashboardViewShell>
|
|
<PageHeaderBar
|
|
accentColor={colors.cyan}
|
|
icon={Globe}
|
|
title="Domain manager"
|
|
description="Manage your subdomains and custom domains"
|
|
actions={
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => {
|
|
sendDashboardMessage({ type: 'list_domains', includeServerless: true });
|
|
sendDashboardMessage({ type: 'list_custom_domains' });
|
|
}}
|
|
aria-label="Refresh domain list"
|
|
>
|
|
<RefreshCw className="w-4 h-4 shrink-0" aria-hidden />
|
|
<span>Refresh</span>
|
|
</ActionButton>
|
|
}
|
|
/>
|
|
<div className={dashLayout.bodyPadding}>
|
|
<DomainManager
|
|
userId={user?.id}
|
|
username={user?.username}
|
|
servers={servers}
|
|
sendMessage={sendDashboardMessage}
|
|
activeTab={activeTab}
|
|
showAlert={showAlert}
|
|
showConfirm={showConfirm}
|
|
/>
|
|
</div>
|
|
</DashboardViewShell>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'tickets' && (
|
|
<TicketSystem />
|
|
)}
|
|
|
|
{activeTab === 'settings' && (
|
|
<div className="w-full space-y-6">
|
|
<DashboardViewShell>
|
|
<PageHeaderBar
|
|
accentColor={colors.magenta}
|
|
icon={Settings}
|
|
title="Settings"
|
|
description="Manage your account and preferences"
|
|
/>
|
|
<div className={dashLayout.bodyPadding}>
|
|
<SectionCard title="Security" icon={Shield} color={colors.green}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<SettingsPanel
|
|
title="Email verification"
|
|
description="Confirm your email to secure account recovery and billing notices."
|
|
>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div
|
|
className="p-2.5 rounded-xl shrink-0 border border-dash-border"
|
|
style={{ backgroundColor: `${userData?.email_verified ? colors.green : colors.yellow}18` }}
|
|
>
|
|
{userData?.email_verified ? (
|
|
<CheckCircle className="w-5 h-5 text-brightGreen" aria-hidden />
|
|
) : (
|
|
<AlertCircle className="w-5 h-5 text-brightYellow" aria-hidden />
|
|
)}
|
|
</div>
|
|
<span
|
|
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border border-dash-border"
|
|
style={{
|
|
backgroundColor: userData?.email_verified ? `${colors.green}18` : `${colors.yellow}18`,
|
|
color: userData?.email_verified ? colors.brightGreen : colors.brightYellow,
|
|
}}
|
|
>
|
|
{userData?.email_verified ? 'Verified' : 'Pending'}
|
|
</span>
|
|
</div>
|
|
{!userData?.email_verified && (
|
|
<ActionButton
|
|
variant="secondary"
|
|
className="shrink-0 !px-4 !py-2 !min-h-0 sm:!min-h-0"
|
|
onClick={async () => {
|
|
try {
|
|
const token = localStorage.getItem('anchorhost_token');
|
|
const response = await fetch(`${API_BASE_URL}/api/user/resend-verification`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Verification email sent',
|
|
message:
|
|
'A new verification email has been sent. Please check your inbox and spam folder.',
|
|
type: 'success',
|
|
});
|
|
} else {
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Failed to send verification',
|
|
message: `Failed to send verification email: ${data.error || 'Unknown error'}`,
|
|
type: 'error',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error resending verification:', error);
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Error',
|
|
message:
|
|
'An error occurred while sending the verification email. Please try again.',
|
|
type: 'error',
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<Mail className="w-4 h-4 shrink-0" aria-hidden />
|
|
<span>Resend email</span>
|
|
</ActionButton>
|
|
)}
|
|
</div>
|
|
</SettingsPanel>
|
|
|
|
<SettingsPanel
|
|
title="Two-factor authentication"
|
|
description="Add a second step at sign-in. Recommended for accounts with payment methods."
|
|
>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="p-2.5 rounded-xl border border-dash-border"
|
|
style={{ backgroundColor: `${colors.magenta}18` }}
|
|
>
|
|
<Lock className="w-5 h-5" style={{ color: colors.magenta }} aria-hidden />
|
|
</div>
|
|
<p className="text-sm text-dash-textMuted">
|
|
{userData.two_factor_enabled ? '2FA is on for this account.' : '2FA is off.'}
|
|
</p>
|
|
</div>
|
|
{userData.two_factor_enabled ? (
|
|
<ActionButton
|
|
variant="danger"
|
|
className="shrink-0 !px-4 !py-2 !min-h-0 sm:!min-h-0"
|
|
onClick={() => {
|
|
const otp = window.prompt('Enter your authentication code to disable 2FA:');
|
|
if (otp) {
|
|
sendDashboardMessage({
|
|
type: '2fa_disable',
|
|
otp_code: otp,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Disable 2FA
|
|
</ActionButton>
|
|
) : (
|
|
<ActionButton
|
|
variant="success"
|
|
className="shrink-0 !px-4 !py-2 !min-h-0 sm:!min-h-0"
|
|
onClick={() => sendDashboardMessage({ type: '2fa_setup' })}
|
|
>
|
|
Enable 2FA
|
|
</ActionButton>
|
|
)}
|
|
</div>
|
|
</SettingsPanel>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Sign-in & profile" icon={User} color={colors.blue}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<SettingsPanel
|
|
title="Email address"
|
|
description="Used for login and important account notifications."
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="rounded-lg border border-dash-border bg-brightBlack/40 px-3 py-2.5 text-sm">
|
|
<span className="text-dash-textMuted">Current </span>
|
|
<span className="text-dash-text font-medium break-all">{user?.email || 'Not set'}</span>
|
|
{user?.email_verified && (
|
|
<span className="ml-2 inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/15 text-brightGreen border border-green-500/25">
|
|
Verified
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<UnifiedTooltip
|
|
content={
|
|
user?.google_id
|
|
? 'This account uses Google sign-in. Email is managed in your Google account.'
|
|
: 'Change the email you use to sign in. You may need to verify the new address.'
|
|
}
|
|
multiline
|
|
>
|
|
<span className="inline-flex">
|
|
<ActionButton
|
|
variant="success"
|
|
className="!px-4 !py-2 !min-h-0 sm:!min-h-0"
|
|
disabled={!!user?.google_id}
|
|
onClick={() => {
|
|
if (user?.google_id) return;
|
|
setEmailModal({
|
|
open: true,
|
|
currentEmail: user?.email || '',
|
|
title: 'Change Email Address',
|
|
label: 'New Email Address',
|
|
placeholder: 'Enter new email address',
|
|
});
|
|
}}
|
|
>
|
|
Change email
|
|
</ActionButton>
|
|
</span>
|
|
</UnifiedTooltip>
|
|
</div>
|
|
</div>
|
|
</SettingsPanel>
|
|
|
|
<SettingsPanel
|
|
title="Password"
|
|
description={
|
|
user?.password_changed_at
|
|
? `Last updated ${new Date(user.password_changed_at).toLocaleString()}`
|
|
: 'Use a strong password you do not reuse elsewhere.'
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="rounded-lg border border-dash-border bg-brightBlack/40 px-3 py-2.5 text-sm">
|
|
{user?.google_id ? (
|
|
<p className="text-dash-textMuted">
|
|
No {branding.name} password — you sign in with Google.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<span className="text-dash-textMuted">Current password </span>
|
|
<span className="text-dash-text font-medium tracking-[0.2em] select-none" aria-hidden>
|
|
••••••••
|
|
</span>
|
|
<span className="sr-only">Hidden for security</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<UnifiedTooltip
|
|
content={
|
|
user?.google_id
|
|
? `Google sign-in accounts do not use a ${branding.name} password. Manage security in Google.`
|
|
: 'Choose a new password. You will need your current password to confirm.'
|
|
}
|
|
multiline
|
|
>
|
|
<span className="inline-flex">
|
|
<ActionButton
|
|
variant="warning"
|
|
className="!px-4 !py-2 !min-h-0 sm:!min-h-0"
|
|
disabled={!!user?.google_id}
|
|
onClick={() => {
|
|
if (user?.google_id) return;
|
|
setPasswordModal({
|
|
open: true,
|
|
title: 'Change Password',
|
|
requireCurrent: true,
|
|
});
|
|
}}
|
|
>
|
|
Change password
|
|
</ActionButton>
|
|
</span>
|
|
</UnifiedTooltip>
|
|
</div>
|
|
</div>
|
|
</SettingsPanel>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Contact & billing" icon={MapPin} color={colors.cyan}>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
const selectedCountryInfo = getSelectedCountry();
|
|
const profileData = {
|
|
first_name: personalInfo.firstName || '',
|
|
last_name: personalInfo.lastName || '',
|
|
address: personalInfo.address || '',
|
|
city: personalInfo.city || '',
|
|
country: selectedCountryInfo?.name || '',
|
|
postal_code: personalInfo.postalCode || '',
|
|
phone: phoneNumber || '',
|
|
};
|
|
|
|
const result = await updateUserProfile(profileData);
|
|
setAlertModal({
|
|
open: true,
|
|
title: result.success ? 'Profile updated' : 'Update failed',
|
|
message: result.message,
|
|
type: result.success ? 'success' : 'error',
|
|
});
|
|
|
|
if (result.success) {
|
|
loadUserProfile();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating profile:', error);
|
|
setAlertModal({
|
|
open: true,
|
|
title: 'Error',
|
|
message: 'An error occurred while updating your profile. Please try again.',
|
|
type: 'error',
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm text-dash-textMuted leading-relaxed max-w-xl">
|
|
Used for invoices, fraud checks, and support. Keep this information accurate.
|
|
</p>
|
|
</div>
|
|
<UnifiedTooltip
|
|
content="Guess country from your network to pre-fill the country selector. You can still edit fields before saving."
|
|
multiline
|
|
>
|
|
<span className="inline-flex shrink-0">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
className="!px-4 !py-2.5 !min-h-0 sm:!min-h-0"
|
|
onClick={detectUserLocation}
|
|
disabled={detectingLocation}
|
|
>
|
|
{detectingLocation ? (
|
|
<RefreshCw className="w-4 h-4 animate-spin shrink-0" aria-hidden />
|
|
) : (
|
|
<MapPin className="w-4 h-4 shrink-0" aria-hidden />
|
|
)}
|
|
<span>{detectingLocation ? 'Detecting…' : 'Auto-detect country'}</span>
|
|
</ActionButton>
|
|
</span>
|
|
</UnifiedTooltip>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted" htmlFor="settings-first-name">
|
|
First name
|
|
</label>
|
|
<input
|
|
id="settings-first-name"
|
|
type="text"
|
|
value={personalInfo.firstName || ''}
|
|
onChange={(e) => setPersonalInfo((prev) => ({ ...prev, firstName: e.target.value }))}
|
|
className={settingsInputClass}
|
|
placeholder="First name"
|
|
autoComplete="given-name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted" htmlFor="settings-last-name">
|
|
Last name
|
|
</label>
|
|
<input
|
|
id="settings-last-name"
|
|
type="text"
|
|
value={personalInfo.lastName || ''}
|
|
onChange={(e) => setPersonalInfo((prev) => ({ ...prev, lastName: e.target.value }))}
|
|
className={settingsInputClass}
|
|
placeholder="Last name"
|
|
autoComplete="family-name"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted">Country</label>
|
|
<Dropdown
|
|
value={selectedCountry}
|
|
options={countryOptions}
|
|
onChange={(value) => {
|
|
setSelectedCountry(value);
|
|
const country = countryOptions.find((c) => c.value === value);
|
|
if (country) {
|
|
setPersonalInfo((prev) => ({ ...prev, country: country.name }));
|
|
}
|
|
}}
|
|
placeholder="Select country"
|
|
icon={Globe}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted" htmlFor="settings-phone">
|
|
Phone
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-28 sm:w-32 shrink-0 py-3 px-2 rounded-xl text-sm text-center tabular-nums border border-dash-border bg-brightBlack/60 text-dash-text"
|
|
aria-hidden
|
|
>
|
|
{getSelectedCountry()?.dialCode || '—'}
|
|
</div>
|
|
<input
|
|
id="settings-phone"
|
|
type="text"
|
|
value={phoneNumber || ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value.replace(/[^\d+]/g, '');
|
|
setPhoneNumber(value);
|
|
}}
|
|
className={`${settingsInputClass} flex-1 min-w-0`}
|
|
placeholder="National number"
|
|
autoComplete="tel-national"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted" htmlFor="settings-address">
|
|
Street address
|
|
</label>
|
|
<input
|
|
id="settings-address"
|
|
type="text"
|
|
value={personalInfo.address || ''}
|
|
onChange={(e) => setPersonalInfo((prev) => ({ ...prev, address: e.target.value }))}
|
|
className={settingsInputClass}
|
|
placeholder="Street, building, unit"
|
|
autoComplete="street-address"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted" htmlFor="settings-city">
|
|
City
|
|
</label>
|
|
<input
|
|
id="settings-city"
|
|
type="text"
|
|
value={personalInfo.city || ''}
|
|
onChange={(e) => setPersonalInfo((prev) => ({ ...prev, city: e.target.value }))}
|
|
className={settingsInputClass}
|
|
placeholder="City"
|
|
autoComplete="address-level2"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-dash-textMuted" htmlFor="settings-postal">
|
|
Postal code
|
|
</label>
|
|
<input
|
|
id="settings-postal"
|
|
type="text"
|
|
value={personalInfo.postalCode || ''}
|
|
onChange={(e) => setPersonalInfo((prev) => ({ ...prev, postalCode: e.target.value }))}
|
|
className={settingsInputClass}
|
|
placeholder="Postal code"
|
|
autoComplete="postal-code"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2 border-t border-dash-border">
|
|
<ActionButton type="submit" variant="success" className="!px-8 !py-3">
|
|
<Check className="w-4 h-4 shrink-0" aria-hidden />
|
|
<span>Save contact details</span>
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Currency & pricing display" icon={Globe} color={colors.blue}>
|
|
<SettingsPanel
|
|
title="Preferred currency"
|
|
description="Prices in the panel and store convert using this currency where supported."
|
|
>
|
|
<div className="space-y-4">
|
|
<Dropdown
|
|
value={selectedCurrency}
|
|
options={currencies.map((currency) => ({
|
|
value: currency.code,
|
|
label: (
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="text-base" aria-hidden>
|
|
{currencyFlags[currency.code] || '💱'}
|
|
</span>
|
|
<div className="flex flex-col items-start min-w-0">
|
|
<span className="text-sm font-medium text-dash-text">{currency.code}</span>
|
|
<span className="text-xs text-dash-textMuted truncate max-w-[12rem]">{currency.name}</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
}))}
|
|
onChange={(value) => {
|
|
setSelectedCurrency(value);
|
|
if (onCurrencyChange && typeof onCurrencyChange === 'function') {
|
|
onCurrencyChange(value);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex items-center gap-3 pt-1 rounded-lg border border-dash-border bg-brightBlack/30 px-3 py-2.5 w-fit">
|
|
<span className="text-lg" aria-hidden>
|
|
{currencyFlags[selectedCurrency] || '💱'}
|
|
</span>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-sm font-medium tabular-nums text-dash-text">
|
|
{currencies.find((c) => c.code === selectedCurrency)?.symbol || '$'}
|
|
</span>
|
|
<span className="text-xs text-dash-textMuted">{selectedCurrency}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsPanel>
|
|
</SectionCard>
|
|
|
|
{/* Support & Contact Information */}
|
|
{/* <SectionCard title="Support & Contact" icon={Headphones} color={colors.magenta}>
|
|
<div className="space-y-6">
|
|
<div className="p-6 rounded-xl border text-center" style={{ backgroundColor: colors.brightBlack, borderColor: colors.magenta }}>
|
|
<Headphones className="w-12 h-12 mx-auto mb-4" style={{ color: colors.magenta }} />
|
|
<h4 className="text-xl font-bold mb-2" style={{ color: colors.brightWhite }}>Need Help?</h4>
|
|
<p className="text-sm mb-4" style={{ color: colors.foreground }}>
|
|
Our support team is here to help you with any questions or issues you might have.
|
|
</p>
|
|
|
|
<div className="space-y-3 max-w-md mx-auto">
|
|
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
|
|
<span style={{ color: colors.foreground }}>Email Support:</span>
|
|
<a
|
|
href={`mailto:${branding.supportEmailAddress}`}
|
|
className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
|
|
style={{ color: colors.magenta }}
|
|
>
|
|
{branding.supportEmailAddress}
|
|
</a>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
|
|
<span style={{ color: colors.foreground }}>Sales Inquiries:</span>
|
|
<a
|
|
href={`mailto:${branding.salesEmail}`}
|
|
className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
|
|
style={{ color: colors.magenta }}
|
|
>
|
|
{branding.salesEmail}
|
|
</a>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-3 rounded-lg" style={{ backgroundColor: colors.black }}>
|
|
<span style={{ color: colors.foreground }}>Business Contact:</span>
|
|
<a
|
|
href={`mailto:${branding.supportEmail}`}
|
|
className="font-semibold hover:underline transition-all duration-200 hover:scale-105"
|
|
style={{ color: colors.magenta }}
|
|
>
|
|
{branding.supportEmail}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 pt-6 border-t" style={{ borderColor: colors.brightBlack }}>
|
|
<p className="text-xs" style={{ color: colors.foreground }}>
|
|
Typical response time: <span style={{ color: colors.green }}>Within 24 hours</span>
|
|
</p>
|
|
<p className="text-xs mt-1" style={{ color: colors.foreground }}>
|
|
Support available: <span style={{ color: colors.green }}>Monday - Friday, 9:00 - 17:00 CET</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<button
|
|
onClick={() => setActiveTab('tickets')}
|
|
className="p-4 rounded-xl border transition-all duration-200 hover:scale-105 text-center"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.blue,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
<MessageCircle className="w-6 h-6 mx-auto mb-2" style={{ color: colors.blue }} />
|
|
<span className="font-semibold">Open Support Ticket</span>
|
|
</button>
|
|
|
|
<a
|
|
href={branding.documentationUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="p-4 rounded-xl border transition-all duration-200 hover:scale-105 text-center"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.green,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
<BookOpen className="w-6 h-6 mx-auto mb-2" style={{ color: colors.green }} />
|
|
<span className="font-semibold">Documentation</span>
|
|
</a>
|
|
|
|
<a
|
|
href="https://discord.gg/mthfJRNYJC"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="p-4 rounded-xl border transition-all duration-200 hover:scale-105 text-center"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.yellow,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
<Users className="w-6 h-6 mx-auto mb-2" style={{ color: colors.yellow }} />
|
|
<span className="font-semibold">Community Forum</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</SectionCard> */}
|
|
|
|
|
|
|
|
<SectionCard title="Data & account" icon={AlertTriangle} color={colors.red}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<SettingsPanel
|
|
title="Export your data"
|
|
description="Download a JSON copy of the personal data we hold for GDPR / portability."
|
|
>
|
|
<ActionButton variant="warning" className="!px-4 !py-2 !min-h-0 sm:!min-h-0 w-full sm:w-auto" onClick={exportUserData}>
|
|
Download JSON export
|
|
</ActionButton>
|
|
</SettingsPanel>
|
|
<SettingsPanel
|
|
title="Delete account"
|
|
description="This removes your account, servers, and billing profile. This cannot be undone."
|
|
>
|
|
<ActionButton
|
|
variant="danger"
|
|
className="!px-4 !py-2 !min-h-0 sm:!min-h-0 w-full sm:w-auto"
|
|
onClick={() => setDeleteAccountModal(true)}
|
|
>
|
|
Delete account…
|
|
</ActionButton>
|
|
</SettingsPanel>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
</div>
|
|
</DashboardViewShell>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Enhanced Server Details Modal */}
|
|
{selectedServer && createPortal(
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-md flex items-center justify-center z-[9998] p-4 lg:p-8 animate-in fade-in duration-300">
|
|
<DashboardViewShell className={`${isTerminalFullscreen ? 'h-full max-w-full' : 'w-full max-w-6xl h-[min(820px,calc(100vh-4rem))]'} flex flex-col min-h-0 overflow-hidden shadow-2xl shadow-black/50 animate-in zoom-in-95 duration-200`}>
|
|
<div className="shrink-0">
|
|
<PageHeaderBar
|
|
accentColor={colors.blue}
|
|
icon={Server}
|
|
title={selectedServer.name}
|
|
description={
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span
|
|
className="px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider border"
|
|
style={{
|
|
backgroundColor: `${getServerStatusInfo(selectedServer.status).color}15`,
|
|
color: getServerStatusInfo(selectedServer.status).color,
|
|
borderColor: `${getServerStatusInfo(selectedServer.status).color}30`,
|
|
}}
|
|
>
|
|
{getServerStatusInfo(selectedServer.status).label}
|
|
</span>
|
|
<span className="text-[10px] font-bold text-dash-textMuted/40 uppercase tracking-widest">• {selectedServer.type} node</span>
|
|
</div>
|
|
}
|
|
actions={
|
|
<div className="flex items-center gap-2">
|
|
{selectedServer.status === 'Running' ? (
|
|
<>
|
|
<ActionButton
|
|
variant="danger"
|
|
onClick={() => {
|
|
handleServerAction(selectedServer.id, 'stop');
|
|
setSelectedServer({ ...selectedServer, status: 'Stopping' });
|
|
}}
|
|
className="!px-3 !min-h-[40px]"
|
|
>
|
|
<Square className="w-4 h-4" />
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="warning"
|
|
onClick={() => {
|
|
handleServerAction(selectedServer.id, 'restart');
|
|
setSelectedServer({ ...selectedServer, status: 'Restarting' });
|
|
}}
|
|
className="!px-3 !min-h-[40px]"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</ActionButton>
|
|
</>
|
|
) : (
|
|
<ActionButton
|
|
variant="success"
|
|
onClick={() => {
|
|
handleServerAction(selectedServer.id, 'start');
|
|
setSelectedServer({ ...selectedServer, status: 'Starting' });
|
|
}}
|
|
className="!px-3 !min-h-[40px]"
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
</ActionButton>
|
|
)}
|
|
<ActionButton
|
|
variant="danger"
|
|
onClick={() => handleDeleteServer(selectedServer.id)}
|
|
className="!px-3 !min-h-[40px]"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</ActionButton>
|
|
<div className="w-px h-6 mx-1 bg-dash-border"></div>
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => setIsTerminalFullscreen(!isTerminalFullscreen)}
|
|
className="!px-3 !min-h-[40px]"
|
|
>
|
|
{isTerminalFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setSelectedServer(null);
|
|
setServerLogs('');
|
|
disconnectTerminal();
|
|
setServerMetrics({ cpu: '0m', memory: '0Mi' });
|
|
setMetricsHistory({ cpu: [], memory: [] });
|
|
setActiveServerDetailTab('overview');
|
|
}}
|
|
className="!px-3 !min-h-[40px]"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</ActionButton>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-b border-dash-border bg-black/5 shrink-0">
|
|
<div className="flex space-x-1 px-6 overflow-x-auto custom-scrollbar">
|
|
{[
|
|
{ id: 'overview', label: 'Overview', icon: Info },
|
|
...(selectedServer.type === 'game'
|
|
? [
|
|
{ id: 'game-console', label: 'Game Console', icon: Gamepad2 },
|
|
...(selectedServer.image && selectedServer.image.toLowerCase().includes('minecraft')
|
|
? [
|
|
{ id: 'plugin-store', label: 'Plugin Store', icon: Package },
|
|
{ id: 'minecraft-settings', label: 'Quick Settings', icon: Settings },
|
|
]
|
|
: []),
|
|
]
|
|
: []),
|
|
...(selectedServer.type !== 'database' && selectedServer.type !== 'vps'
|
|
? [{ id: 'files', label: 'Files & SFTP', icon: Folder }]
|
|
: []),
|
|
{ id: 'network', label: 'Network', icon: NetworkIcon },
|
|
{ id: 'backups', label: 'Backups', icon: Database },
|
|
].map((tab) => {
|
|
const Icon = tab.icon;
|
|
const active = activeServerDetailTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => {
|
|
setActiveServerDetailTab(tab.id);
|
|
if (tab.id !== 'network') setShowPortMappingGuide(false);
|
|
}}
|
|
className={`flex items-center space-x-2 px-5 py-4 text-xs font-bold uppercase tracking-widest border-b-2 transition-all duration-200 ${
|
|
active
|
|
? 'border-primary text-primary bg-primary/5'
|
|
: 'border-transparent text-dash-textMuted hover:text-dash-text hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
|
<div className={dashLayout.bodyPadding}>
|
|
{activeServerDetailTab === 'overview' && (
|
|
<div className="max-w-7xl mx-auto space-y-8">
|
|
{/* Top section: Overview and Resources */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 w-full">
|
|
<SectionCard title="Server Overview" icon={Info} color={colors.blue} className="h-full">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
|
{[
|
|
{ label: 'Status', value: getServerStatusInfo(selectedServer.status).label, color: getServerStatusInfo(selectedServer.status).color },
|
|
{ label: 'Type', value: selectedServer.type.charAt(0).toUpperCase() + selectedServer.type.slice(1), color: colors.brightWhite },
|
|
{ label: 'Created', value: parseServerDate(selectedServer.created_at), color: colors.brightWhite },
|
|
...(selectedServer.image ? [{ label: 'Image', value: selectedServer.image, color: colors.brightWhite }] : []),
|
|
].map((item, index) => (
|
|
<div key={index} className="flex justify-between items-center p-4 rounded-xl border transition-all duration-200 hover:bg-white/[0.02]" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
|
|
<span style={{ color: colors.foreground }} className="font-medium">{item.label}</span>
|
|
<span style={{ color: item.color }} className="font-bold">{item.value}</span>
|
|
</div>
|
|
))}
|
|
|
|
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="System Allocation" icon={BarChart3} color={colors.green} className="h-full">
|
|
<div className="flex flex-col h-full gap-5 text-sm">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{[
|
|
{ icon: Cpu, label: 'vCPU', value: selectedServer.cpu, color: colors.blue },
|
|
{ icon: MemoryStick, label: 'RAM', value: selectedServer.ram, color: colors.green },
|
|
{ icon: HardDriveIcon, label: 'SSD', value: selectedServer.storage, color: colors.yellow },
|
|
].map((item, index) => {
|
|
const MetricIcon = item.icon;
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="flex flex-col items-center justify-center p-4 rounded-2xl border text-center transition-all duration-300 hover:scale-[1.02] group/metric"
|
|
style={{ backgroundColor: `${item.color}08`, borderColor: `${item.color}22` }}
|
|
>
|
|
<MetricIcon className="w-5 h-5 mb-2 opacity-60 group-hover/metric:opacity-100 transition-opacity" style={{ color: item.color }} />
|
|
<span className="font-bold text-lg tabular-nums tracking-tight" style={{ color: colors.brightWhite }}>{item.value}</span>
|
|
<span className="text-[10px] font-bold uppercase tracking-widest opacity-50 mt-0.5" style={{ color: item.color }}>{item.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{isEditingResources && (
|
|
<div
|
|
className="space-y-4 p-4 rounded-2xl border"
|
|
style={{ backgroundColor: `${colors.green}06`, borderColor: `${colors.green}20` }}
|
|
>
|
|
{[
|
|
{ id: 'cpu', icon: Cpu, label: 'CPU Cores', editValue: resourceValues.cpu, unit: 'vCPU', min: 1, max: 4, step: 1, color: colors.blue },
|
|
{ id: 'ram', icon: MemoryStick, label: 'RAM Capacity', editValue: resourceValues.ram, unit: 'GB', min: 1, max: 12, step: 1, color: colors.green },
|
|
{ id: 'storage', icon: HardDriveIcon, label: 'SSD Storage', editValue: resourceValues.storage, unit: 'GB', min: 10, max: 100, step: 5, color: colors.yellow },
|
|
].map((item) => (
|
|
<Slider
|
|
key={item.id}
|
|
value={item.editValue}
|
|
min={item.min}
|
|
max={item.max}
|
|
step={item.step}
|
|
onChange={(value) => setResourceValues({ ...resourceValues, [item.id]: value })}
|
|
label={item.label}
|
|
unit={item.unit}
|
|
icon={item.icon}
|
|
color={item.color}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-auto pt-4 border-t border-white/5">
|
|
{!isEditingResources ? (
|
|
<button
|
|
onClick={() => {
|
|
setIsEditingResources(true);
|
|
setResourceValues({
|
|
cpu: parseCpuValue(selectedServer.cpu),
|
|
ram: parseRamValue(selectedServer.ram),
|
|
storage: parseStorageValue(selectedServer.storage),
|
|
});
|
|
}}
|
|
disabled={selectedServer.is_demo === true || selectedServer.is_demo === 1}
|
|
className="w-full flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl border font-bold text-sm transition-all duration-200 hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed group"
|
|
style={{ backgroundColor: `${colors.green}12`, borderColor: `${colors.green}35`, color: colors.brightGreen }}
|
|
>
|
|
<Edit className="w-4 h-4 transition-transform group-hover:rotate-12" />
|
|
Adjust Resources
|
|
</button>
|
|
) : (
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleAdjustServerParameters}
|
|
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition-all duration-200 hover:brightness-110 active:scale-[0.98] flex items-center justify-center gap-2 shadow-lg"
|
|
style={{ backgroundColor: colors.green, color: colors.brightWhite }}
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
Save Changes
|
|
</button>
|
|
<button
|
|
onClick={() => setIsEditingResources(false)}
|
|
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition-all duration-200 hover:bg-white/5 active:scale-[0.98] flex items-center justify-center gap-2 border border-white/10"
|
|
style={{ color: colors.foreground }}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
{/* Minecraft Software Configuration */}
|
|
{((selectedServer.image && selectedServer.image.toLowerCase() === 'minecraft') || (selectedServer.type === 'game' && selectedServer.image && selectedServer.image.toLowerCase().includes('minecraft'))) && (
|
|
<SectionCard title="Software & Environment" icon={Settings} color={colors.cyan} className="w-full">
|
|
<p className="text-xs text-dash-textMuted -mt-4 mb-6 leading-relaxed">
|
|
Minecraft stack — game version, server engine, and JVM runtime.
|
|
</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{[
|
|
{
|
|
key: 'version',
|
|
label: 'Version',
|
|
hint: 'Game release',
|
|
icon: Tag,
|
|
accent: colors.cyan,
|
|
display: selectedServer.version,
|
|
isEditing: isEditingVersion,
|
|
onEdit: () => setIsEditingVersion(true),
|
|
editContent: (
|
|
<InlineDropdown
|
|
value={newVersion}
|
|
options={minecraftVersions.flatMap((v) => v.versions).map((v) => ({ value: v, label: v }))}
|
|
onChange={setNewVersion}
|
|
onSave={() => handleUpdateConfig('version', newVersion)}
|
|
onCancel={() => setIsEditingVersion(false)}
|
|
color={colors.cyan}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'engine',
|
|
label: 'Engine',
|
|
hint: 'Server software',
|
|
icon: Layers,
|
|
accent: colors.magenta,
|
|
display: selectedServer.loader || 'Paper',
|
|
isEditing: isEditingLoader,
|
|
onEdit: () => setIsEditingLoader(true),
|
|
editContent: (
|
|
<InlineDropdown
|
|
value={newLoader}
|
|
options={[
|
|
{ value: 'Paper', label: 'Paper' },
|
|
{ value: 'Vanilla', label: 'Vanilla' },
|
|
{ value: 'Forge', label: 'Forge' },
|
|
{ value: 'Fabric', label: 'Fabric' },
|
|
].filter((opt) => {
|
|
if (opt.value === 'Fabric') {
|
|
const parts = (newVersion || selectedServer.version || '').split('.');
|
|
if (parts.length >= 2 && parseInt(parts[1], 10) < 14) return false;
|
|
}
|
|
return true;
|
|
})}
|
|
onChange={setNewLoader}
|
|
onSave={() => handleUpdateConfig('loader', newLoader)}
|
|
onCancel={() => setIsEditingLoader(false)}
|
|
color={colors.magenta}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'java',
|
|
label: 'Java Version',
|
|
hint: 'JVM runtime',
|
|
icon: Coffee,
|
|
accent: colors.yellow,
|
|
display: selectedServer.java_version || 'Java 21',
|
|
isEditing: isEditingJava,
|
|
onEdit: () => setIsEditingJava(true),
|
|
editContent: (
|
|
<InlineDropdown
|
|
value={newJavaVersion}
|
|
options={[
|
|
{ value: 'Java 25', label: 'Java 25' },
|
|
{ value: 'Java 21', label: 'Java 21' },
|
|
{ value: 'Java 17', label: 'Java 17' },
|
|
{ value: 'Java 11', label: 'Java 11' },
|
|
{ value: 'Java 8', label: 'Java 8' },
|
|
{ value: 'Java 7', label: 'Java 7' },
|
|
]}
|
|
onChange={setNewJavaVersion}
|
|
onSave={() => handleUpdateConfig('java', newJavaVersion)}
|
|
onCancel={() => setIsEditingJava(false)}
|
|
color={colors.yellow}
|
|
/>
|
|
),
|
|
},
|
|
].map((tile) => {
|
|
const TileIcon = tile.icon;
|
|
const isDemo = selectedServer.is_demo === true || selectedServer.is_demo === 1;
|
|
const canEdit = !isDemo;
|
|
return (
|
|
<div
|
|
key={tile.key}
|
|
className="group relative flex flex-col min-h-[148px] p-5 rounded-2xl border border-solid transition-all duration-300 hover:scale-[1.01] outline-none"
|
|
style={{ backgroundColor: `${tile.accent}06`, borderColor: `${tile.accent}28`, borderWidth: '1px' }}
|
|
>
|
|
<div
|
|
className="absolute inset-x-0 top-0 h-px rounded-t-2xl opacity-60"
|
|
style={{ background: `linear-gradient(90deg, transparent, ${tile.accent}80, transparent)` }}
|
|
/>
|
|
<div className="flex items-start justify-between gap-3 mb-4">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div
|
|
className="p-2.5 rounded-xl shrink-0 border border-solid shadow-lg shadow-black/20"
|
|
style={{ backgroundColor: `${tile.accent}14`, borderColor: `${tile.accent}35`, borderWidth: '1px' }}
|
|
>
|
|
<TileIcon className="w-4 h-4" style={{ color: tile.accent }} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-black uppercase tracking-widest" style={{ color: `${tile.accent}CC` }}>
|
|
{tile.label}
|
|
</p>
|
|
<p className="text-[10px] opacity-40 mt-0.5 truncate">{tile.hint}</p>
|
|
</div>
|
|
</div>
|
|
{canEdit && !tile.isEditing && (
|
|
<button
|
|
type="button"
|
|
onClick={tile.onEdit}
|
|
className="focus-skip-global p-2 rounded-lg opacity-50 sm:opacity-0 sm:group-hover:opacity-100 hover:bg-black/30 transition-all shrink-0 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-offset-0"
|
|
style={{ color: tile.accent, '--tw-ring-color': tile.accent }}
|
|
aria-label={`Edit ${tile.label}`}
|
|
>
|
|
<Edit className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="mt-auto">
|
|
{tile.isEditing && canEdit ? (
|
|
tile.editContent
|
|
) : (
|
|
<p className="font-bold text-2xl tabular-nums tracking-tight truncate" style={{ color: colors.brightWhite }}>
|
|
{tile.display}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</SectionCard>
|
|
)}
|
|
|
|
{/* Bottom section: Real-time Metrics */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 w-full">
|
|
<ResourceGraph
|
|
data={metricsHistory.cpu}
|
|
color={colors.blue}
|
|
title="Real-time CPU Load"
|
|
unit="m"
|
|
maxValue={(() => {
|
|
const cpuStr = selectedServer?.cpu || '1';
|
|
let cpuCores = 1;
|
|
if (cpuStr.includes('vCPU')) cpuCores = parseInt(cpuStr.split()[0]) || 1;
|
|
else if (typeof cpuStr === 'string' && /^\d+$/.test(cpuStr.trim())) cpuCores = parseInt(cpuStr.trim()) || 1;
|
|
else { const match = cpuStr.match(/(\d+)/); if (match) cpuCores = parseInt(match[1]) || 1; }
|
|
return cpuCores * 1000;
|
|
})()}
|
|
currentValue={serverMetrics.cpu?.display || '0m'}
|
|
percentage={Math.round(serverMetrics.cpu_percentage || 0)}
|
|
serverStatus={selectedServer?.status}
|
|
/>
|
|
|
|
<ResourceGraph
|
|
data={metricsHistory.memory}
|
|
color={colors.green}
|
|
title="Memory Consumption"
|
|
unit="Mi"
|
|
maxValue={(parseInt(selectedServer.ram) || 1) * 1024}
|
|
currentValue={serverMetrics.memory?.display || '0.0 GB'}
|
|
percentage={Math.round(serverMetrics.memory_percentage || 0)}
|
|
serverStatus={selectedServer?.status}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeServerDetailTab === 'game-console' && (
|
|
<GameConsole
|
|
server={selectedServer}
|
|
sendMessage={sendDashboardMessage}
|
|
isActive={activeServerDetailTab === 'game-console'}
|
|
/>
|
|
)}
|
|
{activeServerDetailTab === 'plugin-store' && selectedServer.image && selectedServer.image.toLowerCase().includes('minecraft') && (
|
|
<div className="h-full">
|
|
<PluginStoreEmbedded serverId={selectedServer.id} />
|
|
</div>
|
|
)}
|
|
{activeServerDetailTab === 'minecraft-settings' && selectedServer.image && selectedServer.image.toLowerCase().includes('minecraft') && (
|
|
<MinecraftSettings
|
|
server={selectedServer}
|
|
settings={mcSettings}
|
|
onSave={handleUpdateMcSettings}
|
|
isLoading={isMcSettingsLoading}
|
|
/>
|
|
)}
|
|
{activeServerDetailTab === 'files' && (
|
|
<div className="space-y-6">
|
|
{selectedServer.sftp_username ? (
|
|
<SectionCard title="SFTP Access" icon={NetworkIcon} color={colors.blue}>
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 flex-1">
|
|
{[
|
|
{ label: 'SFTP Address', value: selectedServer.ip || NODE_IP },
|
|
{ label: 'Port', value: selectedServer.sftp_port || '2022' },
|
|
{ label: 'Username', value: selectedServer.sftp_username },
|
|
{ label: 'Password', value: selectedServer.sftp_password, isPassword: true }
|
|
].map((item, idx) => (
|
|
<div key={idx} className="p-4 rounded-xl border bg-white/5" style={{ borderColor: 'rgba(255,255,255,0.05)' }}>
|
|
<div className="text-xs opacity-50 mb-1">{item.label}</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="font-mono text-sm truncate mr-2" style={{ color: colors.brightWhite }}>
|
|
{item.isPassword ? (window[`show_sftp_pass_${selectedServer.id}`] ? item.value : '••••••••••••') : item.value}
|
|
</div>
|
|
<div className="flex items-center space-x-1">
|
|
{item.isPassword && (
|
|
<button
|
|
onClick={() => {
|
|
window[`show_sftp_pass_${selectedServer.id}`] = !window[`show_sftp_pass_${selectedServer.id}`];
|
|
// Force a re-render by updating a dummy state or just rely on the next update
|
|
// Since we don't have a local state here easily without refactoring,
|
|
// we use a trick to trigger re-render if needed, but standard React pattern is better.
|
|
// For now, let's use a simple approach.
|
|
const btn = document.activeElement;
|
|
if (btn) btn.blur();
|
|
// We will add a small state to DashboardPage to handle this properly
|
|
setToggleSFTPPassword(!toggleSFTPPassword);
|
|
}}
|
|
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors"
|
|
title={window[`show_sftp_pass_${selectedServer.id}`] ? "Hide password" : "Show password"}
|
|
>
|
|
{window[`show_sftp_pass_${selectedServer.id}`] ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(item.value);
|
|
showAlert('Success', `${item.label} copied to clipboard`, 'success');
|
|
}}
|
|
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors"
|
|
>
|
|
<Copy className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-row lg:flex-col gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const sftpUrl = `sftp://${selectedServer.sftp_username}:${selectedServer.sftp_password}@${selectedServer.ip || NODE_IP}:${selectedServer.sftp_port || '2022'}`;
|
|
window.open(sftpUrl, '_blank');
|
|
}}
|
|
className="flex-1 lg:w-full flex items-center justify-center space-x-2 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 hover:scale-105 border"
|
|
style={{ backgroundColor: colors.blue, borderColor: colors.blue, color: colors.brightWhite }} >
|
|
<ExternalLink className="w-4 h-4" />
|
|
<span>Launch SFTP</span>
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const cmd = `sftp -P ${selectedServer.sftp_port || '2022'} ${selectedServer.sftp_username}@${selectedServer.ip || NODE_IP}`;
|
|
navigator.clipboard.writeText(cmd);
|
|
showAlert('Success', 'SFTP command copied to clipboard', 'success');
|
|
}}
|
|
className="flex-1 lg:w-full flex items-center justify-center space-x-2 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 hover:scale-105 border bg-white/5"
|
|
style={{ borderColor: 'rgba(255,255,255,0.1)', color: colors.brightWhite }}
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
<span>Copy SSH Cmd</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
) : (
|
|
<SectionCard title="SFTP Access" icon={NetworkIcon} color={colors.yellow}>
|
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
<div className="p-4 rounded-full mb-4" style={{ backgroundColor: `${colors.yellow}20` }}>
|
|
<NetworkIcon className="w-8 h-8" style={{ color: colors.yellow }} />
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2" style={{ color: colors.brightWhite }}>SFTP Not Provisioned</h3>
|
|
<p className="text-sm opacity-60 max-w-md mb-6" style={{ color: colors.foreground }}>
|
|
SFTP access allows you to manage files using external clients like FileZilla.
|
|
</p>
|
|
<button
|
|
onClick={() => sendDashboardMessage({ type: 'generate_sftp', serverId: selectedServer.id })}
|
|
className="px-8 py-3 rounded-xl font-bold transition-all duration-200 hover:scale-105 border flex items-center space-x-2"
|
|
style={{ backgroundColor: colors.yellow, borderColor: colors.yellow, color: 'black' }}
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
<span>Provision SFTP Access</span>
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
)}
|
|
|
|
<FileManager
|
|
ref={fileManagerRef}
|
|
serverId={selectedServer.id}
|
|
sendMessage={sendDashboardMessage}
|
|
isActive={activeServerDetailTab === 'files'}
|
|
connectionStatus={dashboardStatus}
|
|
showAlert={showAlert}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{activeServerDetailTab === 'network' && (() => {
|
|
const serverIp = selectedServer.ip || NODE_IP;
|
|
const serverPorts = selectedServer.ports || [];
|
|
const primaryPort = serverPorts[0];
|
|
const mappingCount = serverPorts.length + (selectedServer.sftp_port ? 1 : 0);
|
|
const flowExampleInternal = primaryPort?.internal || '8080';
|
|
const flowExampleExternal = primaryPort?.external || '31234';
|
|
const canExposePorts = ['vps', 'game', 'database'].includes(selectedServer.type);
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
|
<div
|
|
className="rounded-2xl border transition-all duration-300 bg-brightBlack/20 backdrop-blur-sm w-full overflow-hidden"
|
|
style={{ borderColor: `${colors.blue}25`, boxShadow: `0 10px 30px -15px rgba(0,0,0,0.5), 0 0 0 1px ${colors.blue}10` }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPortMappingGuide((open) => !open)}
|
|
className="w-full flex items-center justify-between gap-4 p-5 sm:p-6 text-left transition-colors hover:bg-white/[0.02]"
|
|
>
|
|
<div className="flex items-center gap-4 min-w-0">
|
|
<div
|
|
className="p-3 rounded-xl border shrink-0"
|
|
style={{ backgroundColor: `${colors.blue}15`, borderColor: `${colors.blue}30` }}
|
|
>
|
|
<Info className="w-5 h-5" style={{ color: colors.blue }} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-base sm:text-lg font-bold tracking-tight" style={{ color: colors.brightWhite }}>
|
|
How Port Mapping Works
|
|
</h3>
|
|
<p className="text-xs opacity-50 mt-0.5 truncate sm:whitespace-normal">
|
|
{showPortMappingGuide ? 'Tap to collapse' : 'Port mapping, how to connect, and tips'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
className={`w-5 h-5 shrink-0 opacity-50 transition-transform duration-300 ${showPortMappingGuide ? 'rotate-180' : ''}`}
|
|
style={{ color: colors.blue }}
|
|
/>
|
|
</button>
|
|
|
|
{showPortMappingGuide && (
|
|
<div className="px-5 sm:px-6 pb-6 border-t border-white/5 animate-in slide-in-from-top-2 duration-200">
|
|
<p className="text-sm leading-relaxed opacity-60 mb-6 mt-5 max-w-3xl">
|
|
Your server listens on an <span className="text-white font-semibold">internal port</span> inside its container.
|
|
{branding.name} maps it to a unique <span className="text-white font-semibold">external port</span> on our public IP so people on the internet can reach it — without exposing your server directly.
|
|
</p>
|
|
<div className="flex flex-col lg:flex-row items-stretch justify-center gap-3 lg:gap-4">
|
|
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
|
|
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mb-3">Inside your server</p>
|
|
<code className="text-sm font-bold font-mono" style={{ color: colors.brightWhite }}>:{flowExampleInternal}</code>
|
|
<p className="text-[10px] opacity-40 mt-3 leading-relaxed">What your app or game listens on</p>
|
|
</div>
|
|
<div className="flex items-center justify-center py-1 lg:py-0">
|
|
<ArrowRight className="w-5 h-5 text-primary rotate-90 lg:rotate-0 shrink-0 opacity-60" />
|
|
</div>
|
|
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}25` }}>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-primary mb-3">{branding.name} gateway</p>
|
|
<NetworkIcon className="w-6 h-6 mx-auto text-primary mb-1" />
|
|
<p className="text-[10px] opacity-50 mt-2 leading-relaxed">Secure NodePort + firewall</p>
|
|
</div>
|
|
<div className="flex items-center justify-center py-1 lg:py-0">
|
|
<ArrowRight className="w-5 h-5 text-primary rotate-90 lg:rotate-0 shrink-0 opacity-60" />
|
|
</div>
|
|
<div className="flex-1 p-5 rounded-2xl border text-center" style={{ backgroundColor: `${colors.green}08`, borderColor: `${colors.green}20` }}>
|
|
<p className="text-[10px] font-black uppercase tracking-widest mb-3" style={{ color: colors.brightGreen }}>Public address</p>
|
|
<code className="text-sm font-bold font-mono text-primary">{serverIp}:{flowExampleExternal}</code>
|
|
<p className="text-[10px] opacity-40 mt-3 leading-relaxed">What you give to users & clients</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8 pt-8 border-t border-white/5">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-lg border shrink-0" style={{ backgroundColor: `${colors.blue}15`, borderColor: `${colors.blue}30` }}>
|
|
<ExternalLink className="w-4 h-4" style={{ color: colors.blue }} />
|
|
</div>
|
|
<h4 className="text-sm font-bold tracking-tight" style={{ color: colors.brightWhite }}>How to Connect</h4>
|
|
</div>
|
|
<p className="text-xs leading-relaxed opacity-50 mb-4">{getNetworkConnectionHint(selectedServer)}</p>
|
|
<div className="p-5 rounded-2xl border space-y-4" style={{ backgroundColor: `${colors.primary}05`, borderColor: `${colors.primary}15` }}>
|
|
{[
|
|
{ step: '01', title: 'Copy your address', body: 'Pick a port from the table below and copy its public IP:port address.' },
|
|
{ step: '02', title: 'Open your client', body: selectedServer.type === 'game' ? 'Launch your game and open Multiplayer → Direct Connect.' : 'Open SSH, RDP, a browser, or your database tool.' },
|
|
{ step: '03', title: 'Connect', body: `Paste the full address, e.g. ${serverIp}:${flowExampleExternal}` },
|
|
].map((item) => (
|
|
<div key={item.step} className="flex items-start gap-4">
|
|
<div className="w-8 h-8 rounded-xl flex items-center justify-center shrink-0 border" style={{ backgroundColor: `${colors.primary}15`, borderColor: `${colors.primary}30` }}>
|
|
<span className="text-xs font-black text-primary">{item.step}</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold mb-0.5" style={{ color: colors.brightWhite }}>{item.title}</p>
|
|
<p className="text-xs leading-relaxed opacity-60">{item.body}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-lg border shrink-0" style={{ backgroundColor: `${colors.green}15`, borderColor: `${colors.green}30` }}>
|
|
<Shield className="w-4 h-4" style={{ color: colors.brightGreen }} />
|
|
</div>
|
|
<h4 className="text-sm font-bold tracking-tight" style={{ color: colors.brightWhite }}>Good to Know</h4>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{[
|
|
{
|
|
icon: Globe,
|
|
iconColor: colors.blue,
|
|
title: 'External ports are assigned for you',
|
|
body: 'For security, public ports are unique and randomly chosen. Your internal port stays whatever your service expects.',
|
|
},
|
|
{
|
|
icon: Shield,
|
|
iconColor: colors.brightGreen,
|
|
title: 'Firewall opens automatically',
|
|
body: `When you expose a port, ${branding.name} opens it on the edge firewall in real time — no manual rules needed.`,
|
|
},
|
|
{
|
|
icon: Lock,
|
|
iconColor: colors.yellow,
|
|
title: 'System ports cannot be removed',
|
|
body: 'Default service ports (game, SSH, SFTP) are managed by the platform and marked as System in the table.',
|
|
},
|
|
].map((item, idx) => {
|
|
const TipIcon = item.icon;
|
|
return (
|
|
<div key={idx} className="flex items-start gap-4 p-4 rounded-xl border transition-all duration-200 hover:bg-white/[0.02]" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
|
|
<div className="p-2 rounded-lg shrink-0" style={{ backgroundColor: `${item.iconColor}15` }}>
|
|
<TipIcon className="w-4 h-4" style={{ color: item.iconColor }} />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-bold mb-1" style={{ color: colors.brightWhite }}>{item.title}</p>
|
|
<p className="text-[10px] leading-relaxed opacity-45">{item.body}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<SectionCard
|
|
title="Port Mappings"
|
|
icon={NetworkIcon}
|
|
color={colors.primary}
|
|
className="w-full"
|
|
>
|
|
<p className="text-xs opacity-50 mb-6 -mt-2">
|
|
Internal ports are inside your server. External ports are on our public IP ({serverIp}) — always use the external address when connecting from outside.
|
|
</p>
|
|
|
|
<div className="rounded-2xl border overflow-hidden mb-6" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b" style={{ borderColor: `${colors.white}08`, backgroundColor: `${colors.white}03` }}>
|
|
<th className="px-5 py-4 text-[10px] font-black uppercase tracking-widest opacity-40">Service</th>
|
|
<th className="px-5 py-4 text-[10px] font-black uppercase tracking-widest opacity-40">Internal</th>
|
|
<th className="px-5 py-4 text-[10px] font-black uppercase tracking-widest opacity-40">External (public)</th>
|
|
<th className="px-5 py-4 text-[10px] font-black uppercase tracking-widest opacity-40">Protocol</th>
|
|
<th className="px-5 py-4 text-[10px] font-black uppercase tracking-widest opacity-40 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/5">
|
|
{selectedServer.sftp_port && (
|
|
<tr className="group transition-colors hover:bg-white/[0.02]">
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />
|
|
<div>
|
|
<span className="text-sm font-bold" style={{ color: colors.brightWhite }}>SFTP Access</span>
|
|
<p className="text-[10px] opacity-40 mt-0.5">File transfers via Files tab</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<code className="text-xs px-2 py-1 rounded font-mono border" style={{ backgroundColor: 'rgba(0,0,0,0.4)', borderColor: 'rgba(255,255,255,0.05)', color: colors.foreground }}>22</code>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<code className="text-xs px-2.5 py-1.5 rounded font-mono font-bold border text-blue-400" style={{ backgroundColor: `${colors.blue}15`, borderColor: `${colors.blue}25` }}>
|
|
{serverIp}:{selectedServer.sftp_port}
|
|
</code>
|
|
<UnifiedTooltip content="System-managed — cannot be removed">
|
|
<Shield className="w-3.5 h-3.5 opacity-40" style={{ color: colors.blue }} />
|
|
</UnifiedTooltip>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<span className="text-[10px] font-black uppercase px-2.5 py-1 rounded-lg border opacity-60" style={{ backgroundColor: `${colors.white}05`, borderColor: `${colors.white}08` }}>TCP</span>
|
|
</td>
|
|
<td className="px-5 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(`${serverIp}:${selectedServer.sftp_port}`);
|
|
setAlertModal({ open: true, title: 'Copied', message: 'SFTP address copied to clipboard', type: 'success' });
|
|
}}
|
|
className="p-2 rounded-lg transition-all duration-200 hover:bg-white/10"
|
|
style={{ color: colors.primary }}
|
|
>
|
|
<Copy className="w-4 h-4" style={{ color: 'inherit' }} />
|
|
</button>
|
|
<div className="flex items-center gap-1.5 px-2 py-1 opacity-30">
|
|
<Shield className="w-3.5 h-3.5" />
|
|
<span className="text-[9px] font-black uppercase tracking-widest">System</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{serverPorts.map((port, index) => {
|
|
const isDefault = isDefaultPort(selectedServer, port);
|
|
const portTooltip = getPortTooltip(parseInt(port.internal, 10), port.external);
|
|
const shouldShowTooltip = portTooltip && (selectedServer.type === 'vps' || isDefault);
|
|
const serviceName = getPortServiceName(selectedServer, port, isDefault);
|
|
|
|
return (
|
|
<tr key={index} className="group transition-colors hover:bg-white/[0.02]">
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-2 h-2 rounded-full shrink-0 ${isDefault ? 'bg-primary' : 'bg-white/30'}`} />
|
|
<div>
|
|
<span className="text-sm font-bold" style={{ color: colors.brightWhite }}>{serviceName}</span>
|
|
{portTooltip && (
|
|
<p className="text-[10px] opacity-40 mt-0.5 max-w-[200px] truncate">{portTooltip.description}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<code className="text-xs px-2 py-1 rounded font-mono border" style={{ backgroundColor: 'rgba(0,0,0,0.4)', borderColor: 'rgba(255,255,255,0.05)', color: colors.foreground }}>{port.internal}</code>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<code className="text-xs px-2.5 py-1.5 rounded font-mono font-bold border text-primary" style={{ backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}20` }}>
|
|
{serverIp}:{port.external}
|
|
</code>
|
|
{shouldShowTooltip && <PortTooltip portTooltip={portTooltip} />}
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<span className="text-[10px] font-black uppercase px-2.5 py-1 rounded-lg border opacity-60" style={{ backgroundColor: `${colors.white}05`, borderColor: `${colors.white}08` }}>
|
|
{port.protocol || 'TCP'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(`${serverIp}:${port.external}`);
|
|
setAlertModal({ open: true, title: 'Success', message: 'Address copied to clipboard', type: 'success' });
|
|
}}
|
|
className="p-2 rounded-lg transition-all duration-200 hover:bg-white/10"
|
|
style={{ color: colors.primary }}
|
|
>
|
|
<UnifiedTooltip content="Copy address">
|
|
<Copy className="w-4 h-4" style={{ color: 'inherit' }} />
|
|
</UnifiedTooltip>
|
|
</button>
|
|
{!isDefault ? (
|
|
<button
|
|
onClick={() => {
|
|
showConfirm(
|
|
'Remove port mapping?',
|
|
`Remove the public mapping for internal port ${port.internal} (${port.protocol || 'TCP'})? Players and clients will no longer reach this service on the external address.`,
|
|
() => handleRemovePort(selectedServer.id, port.internal, port.protocol),
|
|
'danger',
|
|
{ confirmLabel: 'Yes', cancelLabel: 'No' }
|
|
);
|
|
}}
|
|
className="p-2 rounded-lg transition-all duration-200 hover:bg-red-500/20"
|
|
style={{ color: colors.red }}
|
|
>
|
|
<UnifiedTooltip content="Remove mapping">
|
|
<Trash2 className="w-4 h-4" style={{ color: colors.brightRed }} />
|
|
</UnifiedTooltip>
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 px-2 py-1 opacity-30">
|
|
<Shield className="w-3.5 h-3.5" />
|
|
<span className="text-[9px] font-black uppercase tracking-widest">System</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{mappingCount === 0 && (
|
|
<div className="py-16 px-6 text-center">
|
|
<NetworkIcon className="w-10 h-10 mx-auto mb-4 opacity-15" />
|
|
<p className="text-sm font-bold opacity-40 mb-1">No port mappings yet</p>
|
|
<p className="text-xs opacity-30 max-w-sm mx-auto leading-relaxed">
|
|
{selectedServer.status === 'running'
|
|
? 'Ports usually appear when the server starts. If yours is running, try refreshing or expose a custom port below.'
|
|
: 'Start your server to provision default ports, or expose a custom port manually.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{canExposePorts && (
|
|
<div className="p-6 sm:p-8 rounded-2xl border-2 border-dashed flex flex-col items-center justify-center space-y-5 w-full" style={{ borderColor: `${colors.primary}25`, backgroundColor: `${colors.primary}05` }}>
|
|
<div className="text-center">
|
|
<h5 className="text-sm font-black uppercase tracking-[0.2em] mb-2" style={{ color: colors.primary }}>Expose New Port</h5>
|
|
<p className="text-xs opacity-45 font-medium max-w-md">Enter the port your app listens on inside the server. {branding.name} assigns a public external port automatically.</p>
|
|
</div>
|
|
<form
|
|
onSubmit={(e) => { e.preventDefault(); submitNetworkExposePort(); }}
|
|
className="flex flex-col sm:flex-row items-end gap-4 w-full max-w-xl"
|
|
>
|
|
<div className="flex-1 w-full space-y-2">
|
|
<label className="text-[10px] font-black uppercase tracking-widest opacity-40 ml-1">Internal port</label>
|
|
<input
|
|
type="number"
|
|
value={newPortInternal}
|
|
onChange={(e) => setNewPortInternal(e.target.value)}
|
|
placeholder="e.g. 8080"
|
|
min="1"
|
|
max="65535"
|
|
className="w-full rounded-xl px-4 py-3 text-sm font-bold focus:outline-none transition-all duration-200 border bg-black/40 text-center placeholder:text-white/20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
style={{ borderColor: colors.brightBlack, color: colors.brightWhite }}
|
|
/>
|
|
</div>
|
|
<div className="w-full sm:w-36 space-y-2">
|
|
<label className="text-[10px] font-black uppercase tracking-widest opacity-40 ml-1">Protocol</label>
|
|
<Dropdown
|
|
value={newPortProtocol}
|
|
options={[{ value: 'TCP', label: 'TCP' }, { value: 'UDP', label: 'UDP' }]}
|
|
onChange={setNewPortProtocol}
|
|
placeholder="Protocol"
|
|
icon={NetworkIcon}
|
|
/>
|
|
</div>
|
|
<ActionButton type="submit" variant="primary" className="w-full sm:w-auto !h-[46px] !px-8 shadow-lg shadow-primary/20" disabled={!newPortInternal.trim()}>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Expose Port</span>
|
|
</ActionButton>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{activeServerDetailTab === 'backups' && (
|
|
<BackupSection
|
|
serverId={selectedServer.id}
|
|
sendMessage={sendDashboardMessage}
|
|
lastMessage={lastMessage}
|
|
/>
|
|
)}
|
|
|
|
{activeServerDetailTab === 'terminal' && (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h4 className="font-semibold flex items-center text-lg" style={{ color: colors.brightWhite }}>
|
|
<Terminal className="w-5 h-5 mr-3" />
|
|
Terminal
|
|
</h4>
|
|
<div className="flex items-center space-x-3">
|
|
<span className={`text-xs px-4 py-2 rounded-xl border transition-all duration-200 ${
|
|
isTerminalConnected ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-red-500/20 text-red-400 border-red-500/30'
|
|
}`}>
|
|
{isTerminalConnected ? 'Connected' : 'Disconnected'}
|
|
</span>
|
|
{isTerminalConnected ? (
|
|
<button
|
|
onClick={disconnectTerminal}
|
|
className="px-5 py-3 text-sm rounded-xl transition-all duration-200 hover:scale-105 border"
|
|
style={{ backgroundColor: `${colors.red}20`, borderColor: colors.red, color: colors.brightRed }}
|
|
>
|
|
Disconnect
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={connectTerminal}
|
|
className="px-5 py-3 text-sm rounded-xl transition-all duration-200 hover:scale-105 border"
|
|
style={{ backgroundColor: `${colors.green}20`, borderColor: colors.green, color: colors.brightGreen }}
|
|
>
|
|
Connect
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl overflow-hidden border transition-all duration-200" style={{ borderColor: colors.brightBlack }}>
|
|
<div
|
|
ref={terminalRef}
|
|
className="p-6 font-mono text-sm whitespace-pre-wrap overflow-y-auto"
|
|
style={{
|
|
height: '400px',
|
|
backgroundColor: colors.brightBlack,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
{terminalOutput || "Connect to server to use terminal"}
|
|
</div>
|
|
|
|
{isTerminalConnected && (
|
|
<div className="flex items-center border-t p-4" style={{ borderColor: colors.brightBlack, backgroundColor: colors.brightBlack }}>
|
|
<span className="mr-3" style={{ color: colors.green }}>$</span>
|
|
<input
|
|
type="text"
|
|
value={terminalInput}
|
|
onChange={(e) => setTerminalInput(e.target.value)}
|
|
onKeyDown={handleTerminalKeyDown}
|
|
className="flex-1 bg-transparent outline-none font-mono py-2"
|
|
style={{ color: colors.brightWhite }}
|
|
placeholder="Type command and press Enter"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeServerDetailTab === 'logs' && (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h4 className="font-semibold flex items-center text-lg" style={{ color: colors.brightWhite }}>
|
|
<Terminal className="w-5 h-5 mr-3" />
|
|
Server Logs
|
|
</h4>
|
|
<button
|
|
onClick={() => fetchServerLogs(selectedServer.id)}
|
|
className="px-5 py-3 text-sm rounded-xl flex items-center space-x-2 transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.blue,
|
|
borderColor: colors.blue,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
<span>Refresh</span>
|
|
</button>
|
|
</div>
|
|
<div className="rounded-xl p-6 font-mono text-sm whitespace-pre-wrap overflow-x-auto max-h-96 overflow-y-auto border transition-all duration-200"
|
|
style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack, color: colors.brightWhite }}>
|
|
{serverLogs || "No logs available"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DashboardViewShell>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* ALL MODALS */}
|
|
{displayNameModal.open && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[9999]">
|
|
<div className="rounded-2xl w-full max-w-md border" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
|
|
<div className="p-6 border-b flex justify-between items-center" style={{ borderColor: colors.brightBlack }}>
|
|
<h3 className="text-lg font-semibold" style={{ color: colors.brightWhite }}>{displayNameModal.title}</h3>
|
|
<button
|
|
onClick={() => setDisplayNameModal({ open: false, currentName: '', field: '', title: '', label: '', placeholder: '' })}
|
|
className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.foreground
|
|
}}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium mb-3" style={{ color: colors.foreground }}>
|
|
{displayNameModal.label}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={displayNameModal.currentName}
|
|
onChange={(e) => setDisplayNameModal(prev => ({ ...prev, currentName: e.target.value }))}
|
|
className="w-full rounded-xl px-4 py-3 text-sm focus:outline-none transition-all duration-200 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.brightWhite
|
|
}}
|
|
placeholder={displayNameModal.placeholder}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex space-x-3">
|
|
<button
|
|
onClick={() => setDisplayNameModal({ open: false, currentName: '', field: '', title: '', label: '', placeholder: '' })}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
if (displayNameModal.currentName.trim()) {
|
|
const result = await updateUserProfile({ [displayNameModal.field]: displayNameModal.currentName.trim() });
|
|
setAlertModal({
|
|
open: true,
|
|
title: result.success ? "Success" : "Error",
|
|
message: result.message,
|
|
type: result.success ? "success" : "error"
|
|
});
|
|
if (result.success) {
|
|
setDisplayNameModal({ open: false, currentName: '', field: '', title: '', label: '', placeholder: '' });
|
|
}
|
|
}
|
|
}}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.blue,
|
|
borderColor: colors.blue,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{emailModal.open && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[9999]">
|
|
<div
|
|
className="rounded-2xl w-full max-w-md border border-dash-border bg-dash-surfaceElevated/95"
|
|
>
|
|
<div className="p-6 border-b border-dash-border flex justify-between items-center">
|
|
<h3 className="text-lg font-semibold text-dash-text">{emailModal.title}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEmailModal({ open: false, currentEmail: '', title: '', label: '', placeholder: '' })}
|
|
className="p-2 rounded-lg border-0 bg-brightBlack/80 text-dash-textMuted hover:text-dash-text hover:bg-brightBlack transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium mb-3" style={{ color: colors.foreground }}>
|
|
{emailModal.label}
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={emailModal.currentEmail}
|
|
onChange={(e) => setEmailModal(prev => ({ ...prev, currentEmail: e.target.value }))}
|
|
className="w-full rounded-xl px-4 py-3 text-sm border border-dash-border bg-brightBlack/60 text-dash-text placeholder:text-dash-textMuted/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40 focus-visible:border-primary/35 transition-colors"
|
|
placeholder={emailModal.placeholder}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<ActionButton
|
|
variant="secondary"
|
|
className="flex-1 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={() => setEmailModal({ open: false, currentEmail: '', title: '', label: '', placeholder: '' })}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="success"
|
|
className="flex-1 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={async () => {
|
|
if (emailModal.currentEmail.trim()) {
|
|
const result = await changeUserEmail(emailModal.currentEmail.trim());
|
|
setAlertModal({
|
|
open: true,
|
|
title: result.success ? "Success" : "Error",
|
|
message: result.message,
|
|
type: result.success ? "success" : "error"
|
|
});
|
|
if (result.success) {
|
|
setEmailModal({ open: false, currentEmail: '', title: '', label: '', placeholder: '' });
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
Change Email
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{passwordModal.open && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[9999]">
|
|
<div className="rounded-2xl w-full max-w-md border border-dash-border bg-dash-surfaceElevated/95">
|
|
<div className="p-6 border-b border-dash-border flex justify-between items-center">
|
|
<h3 className="text-lg font-semibold text-dash-text">{passwordModal.title}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPasswordModal({ open: false, title: '', requireCurrent: true })}
|
|
className="p-2 rounded-lg border-0 bg-brightBlack/80 text-dash-textMuted hover:text-dash-text hover:bg-brightBlack transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
{passwordModal.requireCurrent && (
|
|
<div>
|
|
<label className="block text-sm font-medium mb-3" style={{ color: colors.foreground }}>Current Password</label>
|
|
<input
|
|
type="password"
|
|
id="currentPassword"
|
|
className="w-full rounded-xl px-4 py-3 text-sm border border-dash-border bg-brightBlack/60 text-dash-text placeholder:text-dash-textMuted/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40 focus-visible:border-primary/35 transition-colors"
|
|
placeholder="Enter current password"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-3" style={{ color: colors.foreground }}>New Password</label>
|
|
<input
|
|
type="password"
|
|
id="newPassword"
|
|
className="w-full rounded-xl px-4 py-3 text-sm border border-dash-border bg-brightBlack/60 text-dash-text placeholder:text-dash-textMuted/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40 focus-visible:border-primary/35 transition-colors"
|
|
placeholder="Enter new password (min 6 characters)"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-3" style={{ color: colors.foreground }}>Confirm New Password</label>
|
|
<input
|
|
type="password"
|
|
id="confirmPassword"
|
|
className="w-full rounded-xl px-4 py-3 text-sm border border-dash-border bg-brightBlack/60 text-dash-text placeholder:text-dash-textMuted/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40 focus-visible:border-primary/35 transition-colors"
|
|
placeholder="Confirm new password"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-6">
|
|
<ActionButton
|
|
variant="secondary"
|
|
className="flex-1 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={() => setPasswordModal({ open: false, title: '', requireCurrent: true })}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="warning"
|
|
className="flex-1 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={async () => {
|
|
const currentPassword = document.getElementById('currentPassword')?.value;
|
|
const newPassword = document.getElementById('newPassword')?.value;
|
|
const confirmPassword = document.getElementById('confirmPassword')?.value;
|
|
|
|
if (!newPassword || newPassword.length < 6) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Invalid Password",
|
|
message: "New password must be at least 6 characters long",
|
|
type: "error"
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Passwords Don't Match",
|
|
message: "New password and confirmation do not match",
|
|
type: "error"
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (passwordModal.requireCurrent && !currentPassword) {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Current Password Required",
|
|
message: "Please enter your current password",
|
|
type: "error"
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = await changeUserPassword(currentPassword, newPassword);
|
|
setAlertModal({
|
|
open: true,
|
|
title: result.success ? "Success" : "Error",
|
|
message: result.message,
|
|
type: result.success ? "success" : "error"
|
|
});
|
|
|
|
if (result.success) {
|
|
setPasswordModal({ open: false, title: '', requireCurrent: true });
|
|
}
|
|
}}
|
|
>
|
|
Change Password
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Convert Demo to Paid Server Modal */}
|
|
{convertDemoModal.open && convertDemoModal.server && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[9999]">
|
|
<div className="rounded-2xl w-full max-w-lg" style={{ backgroundColor: colors.backgroundSecondary }}>
|
|
<div className="p-6 border-b flex justify-between items-center" style={{ borderColor: colors.brightYellow }}>
|
|
<div className="flex items-center space-x-3">
|
|
<Coins className="w-5 h-5" style={{ color: colors.brightYellow }} />
|
|
<h3 className="text-lg font-semibold" style={{ color: colors.brightWhite }}>Convert Demo Server to Paid</h3>
|
|
</div>
|
|
<button
|
|
onClick={() => setConvertDemoModal({ open: false, server: null, cost: 0 })}
|
|
className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.foreground
|
|
}}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="mb-4">
|
|
<p className="text-sm mb-4" style={{ color: colors.foreground }}>
|
|
Convert <strong style={{ color: colors.brightWhite }}>{convertDemoModal.server.name}</strong> from a demo server to a paid server. Credits will be charged based on the server specifications.
|
|
</p>
|
|
|
|
{/* Server Specs Display */}
|
|
<div className="mb-4 p-4 rounded-xl border" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack }}>
|
|
<h4 className="text-sm font-semibold mb-3" style={{ color: colors.brightWhite }}>Server Specifications</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex items-center space-x-2">
|
|
<Cpu className="w-4 h-4" style={{ color: colors.blue }} />
|
|
<div>
|
|
<span className="text-xs" style={{ color: colors.foreground }}>CPU:</span>
|
|
<p className="text-sm font-semibold" style={{ color: colors.brightWhite }}>{convertDemoModal.server.cpu}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<MemoryStick className="w-4 h-4" style={{ color: colors.green }} />
|
|
<div>
|
|
<span className="text-xs" style={{ color: colors.foreground }}>RAM:</span>
|
|
<p className="text-sm font-semibold" style={{ color: colors.brightWhite }}>{convertDemoModal.server.ram}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<HardDrive className="w-4 h-4" style={{ color: colors.yellow }} />
|
|
<div>
|
|
<span className="text-xs" style={{ color: colors.foreground }}>Storage:</span>
|
|
<p className="text-sm font-semibold" style={{ color: colors.brightWhite }}>{convertDemoModal.server.storage}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Server className="w-4 h-4" style={{ color: colors.magenta }} />
|
|
<div>
|
|
<span className="text-xs" style={{ color: colors.foreground }}>Type:</span>
|
|
<p className="text-sm font-semibold" style={{ color: colors.brightWhite }}>{convertDemoModal.server.type?.toUpperCase() || 'VPS'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cost Display */}
|
|
<div className="mb-4 p-4 rounded-xl border" style={{ backgroundColor: `${colors.brightYellow}15`, borderColor: colors.brightYellow }}>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-sm font-semibold" style={{ color: colors.brightWhite }}>Cost to Convert:</span>
|
|
<span className="text-xl font-bold" style={{ color: colors.brightYellow }}>
|
|
{convertDemoModal.cost.toLocaleString()} credits
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs" style={{ color: colors.foreground }}>Your Balance:</span>
|
|
<span className="text-sm font-semibold" style={{ color: userCredits >= convertDemoModal.cost ? colors.green : colors.red }}>
|
|
{userCredits.toLocaleString()} credits
|
|
</span>
|
|
</div>
|
|
{userCredits < convertDemoModal.cost && (
|
|
<div className="mt-2 p-2 rounded border" style={{ backgroundColor: `${colors.red}20`, borderColor: colors.red }}>
|
|
<p className="text-xs" style={{ color: colors.red }}>
|
|
Insufficient credits. You need {convertDemoModal.cost.toLocaleString()} credits but only have {userCredits.toLocaleString()} credits.
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div className="mt-2">
|
|
<p className="text-xs" style={{ color: colors.foreground }}>
|
|
Rate: 100 credits = {getCurrencySymbol()}{calculateCurrencyValue(100)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex space-x-3">
|
|
<button
|
|
onClick={() => setConvertDemoModal({ open: false, server: null, cost: 0 })}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={confirmConvertDemoToPaid}
|
|
disabled={userCredits < convertDemoModal.cost}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{
|
|
backgroundColor: userCredits >= convertDemoModal.cost ? colors.brightYellow : colors.brightBlack,
|
|
borderColor: userCredits >= convertDemoModal.cost ? colors.brightYellow : colors.brightBlack,
|
|
color: userCredits >= convertDemoModal.cost ? colors.black : colors.foreground
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-center space-x-2">
|
|
<Coins className="w-4 h-4" />
|
|
<span>Convert to Paid</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{confirmModal.open && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[10000] p-4 backdrop-blur-sm animate-in fade-in duration-300">
|
|
<div className="rounded-2xl w-full max-w-md border border-dash-border bg-dash-surfaceElevated shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
|
<div className={`p-6 border-b flex justify-between items-center ${
|
|
confirmModal.type === 'danger' ? 'border-red-500/25' : 'border-dash-border'
|
|
}`}>
|
|
<div className="flex items-center space-x-3">
|
|
{confirmModal.type === 'danger' ? (
|
|
<Trash2 className="w-5 h-5 text-brightRed" />
|
|
) : (
|
|
<Info className="w-5 h-5 text-primary" />
|
|
)}
|
|
<h3 className="text-lg font-bold text-dash-text">{confirmModal.title}</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmModal({ ...confirmModal, open: false })}
|
|
className="p-2 rounded-lg text-dash-textMuted hover:bg-white/5 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<p className="text-sm leading-relaxed text-dash-textMuted">
|
|
{confirmModal.message}
|
|
</p>
|
|
|
|
<div className="flex space-x-3 mt-8">
|
|
<button
|
|
onClick={() => setConfirmModal({ ...confirmModal, open: false })}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:bg-white/5 border border-dash-border text-dash-text"
|
|
>
|
|
{confirmModal.cancelLabel}
|
|
</button>
|
|
<ActionButton
|
|
variant={confirmModal.type === 'danger' ? 'danger' : 'primary'}
|
|
onClick={() => {
|
|
confirmModal.onConfirm();
|
|
setConfirmModal({ ...confirmModal, open: false });
|
|
}}
|
|
className="flex-1"
|
|
>
|
|
{confirmModal.confirmLabel}
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{alertModal.open && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[10000]">
|
|
<div className="rounded-2xl w-full max-w-md border border-dash-border bg-dash-surfaceElevated/95">
|
|
<div
|
|
className={`p-6 border-b flex justify-between items-center ${
|
|
alertModal.type === 'error'
|
|
? 'border-red-500/25'
|
|
: alertModal.type === 'success'
|
|
? 'border-green-500/25'
|
|
: alertModal.type === 'warning'
|
|
? 'border-brightYellow/25'
|
|
: 'border-dash-border'
|
|
}`}
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
{alertModal.type === 'success' && (
|
|
<CheckCircle className="w-5 h-5" style={{ color: colors.green }} />
|
|
)}
|
|
{alertModal.type === 'error' && (
|
|
<AlertCircle className="w-5 h-5" style={{ color: colors.red }} />
|
|
)}
|
|
{alertModal.type === 'warning' && (
|
|
<AlertTriangle className="w-5 h-5" style={{ color: colors.yellow }} />
|
|
)}
|
|
{alertModal.type === 'info' && (
|
|
<Info className="w-5 h-5" style={{ color: colors.blue }} />
|
|
)}
|
|
<h3 className="text-lg font-semibold text-dash-text">{alertModal.title}</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAlertModal({ open: false, title: '', message: '', type: 'info' })}
|
|
className="p-2 rounded-lg border-0 bg-brightBlack/80 text-dash-textMuted hover:text-dash-text hover:bg-brightBlack transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<p className="text-sm" style={{ color: colors.foreground }}>{alertModal.message}</p>
|
|
|
|
<div className="flex justify-end mt-6">
|
|
<ActionButton
|
|
variant={
|
|
alertModal.type === 'error'
|
|
? 'danger'
|
|
: alertModal.type === 'success'
|
|
? 'success'
|
|
: alertModal.type === 'warning'
|
|
? 'warning'
|
|
: 'primary'
|
|
}
|
|
className="!px-6 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={() => setAlertModal({ open: false, title: '', message: '', type: 'info' })}
|
|
>
|
|
OK
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{deleteAccountModal && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[10000]">
|
|
<div className="rounded-2xl w-full max-w-md border border-red-500/35 bg-dash-surfaceElevated/95">
|
|
<div className="p-6 border-b border-red-500/25 flex justify-between items-center">
|
|
<div className="flex items-center space-x-3">
|
|
<AlertTriangle className="w-5 h-5" style={{ color: colors.red }} />
|
|
<h3 className="text-lg font-semibold text-dash-text">Delete Account</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteAccountModal(false)}
|
|
className="p-2 rounded-lg border-0 bg-brightBlack/80 text-dash-textMuted hover:text-dash-text hover:bg-brightBlack transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="mb-4 p-4 rounded-xl border border-red-500/30 bg-red-500/10">
|
|
<p className="text-sm text-dash-text">Warning: This action cannot be undone!</p>
|
|
<p className="text-xs mt-1" style={{ color: colors.foreground }}>
|
|
All your servers, domains, tickets, and account data will be permanently deleted.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium mb-3" style={{ color: colors.foreground }}>
|
|
Type <span className="font-bold">DELETE MY ACCOUNT</span> to confirm:
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="deleteConfirmation"
|
|
className="w-full rounded-xl px-4 py-3 text-sm border border-dash-border bg-brightBlack/60 text-dash-text placeholder:text-dash-textMuted/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40 focus-visible:border-primary/35 transition-colors"
|
|
placeholder="DELETE MY ACCOUNT"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<ActionButton
|
|
variant="secondary"
|
|
className="flex-1 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={() => setDeleteAccountModal(false)}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="danger"
|
|
className="flex-1 !py-3 !min-h-0 sm:!min-h-0"
|
|
onClick={async () => {
|
|
const confirmation = document.getElementById('deleteConfirmation')?.value;
|
|
if (confirmation === 'DELETE MY ACCOUNT') {
|
|
await deleteUserAccount(confirmation);
|
|
setDeleteAccountModal(false);
|
|
} else {
|
|
setAlertModal({
|
|
open: true,
|
|
title: "Invalid Confirmation",
|
|
message: "Please type exactly: DELETE MY ACCOUNT",
|
|
type: "error"
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Delete Account
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Delete Server Confirmation Modal */}
|
|
{deleteServerModal.open && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[10000]">
|
|
<div className="rounded-2xl w-full max-w-md border" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.red }}>
|
|
<div className="p-6 border-b flex justify-between items-center" style={{ borderColor: colors.red }}>
|
|
<div className="flex items-center space-x-3">
|
|
<AlertTriangle className="w-5 h-5" style={{ color: colors.red }} />
|
|
<h3 className="text-lg font-semibold" style={{ color: colors.brightWhite }}>Delete Server</h3>
|
|
</div>
|
|
<button
|
|
onClick={() => setDeleteServerModal({ open: false, serverId: null })}
|
|
className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.foreground
|
|
}}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="mb-4 p-4 rounded-xl border" style={{ backgroundColor: `${colors.red}10`, borderColor: colors.red }}>
|
|
<p className="text-sm" style={{ color: colors.brightWhite }}>Warning: This action cannot be undone!</p>
|
|
<p className="text-xs mt-1" style={{ color: colors.foreground }}>
|
|
All server data, files, and configurations will be permanently deleted.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex space-x-3">
|
|
<button
|
|
onClick={() => setDeleteServerModal({ open: false, serverId: null })}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
borderColor: colors.brightBlack,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={confirmDeleteServer}
|
|
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
|
style={{
|
|
backgroundColor: colors.red,
|
|
borderColor: colors.red,
|
|
color: colors.brightWhite
|
|
}}
|
|
>
|
|
Delete Server
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Server Creation Progress Notifier */}
|
|
{serverCreationProgress.visible && createPortal(
|
|
<div className="fixed bottom-0 left-0 right-0 z-[9999] p-4 flex justify-center pointer-events-none">
|
|
<div
|
|
className="w-full max-w-md rounded-xl border shadow-2xl animate-slide-up pointer-events-auto"
|
|
style={{
|
|
backgroundColor: colors.backgroundSecondary,
|
|
borderColor: colors.brightBlack
|
|
}}
|
|
>
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="p-2 rounded-lg" style={{ backgroundColor: `${colors.blue}20` }}>
|
|
<Activity className="w-5 h-5" style={{ color: colors.blue }} />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-semibold" style={{ color: colors.brightWhite }}>
|
|
Server Creation in Progress
|
|
</h4>
|
|
<p className="text-xs mt-0.5" style={{ color: colors.foreground }}>
|
|
{serverCreationProgress.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setServerCreationProgress(prev => ({ ...prev, visible: false }))}
|
|
className="p-1 rounded-lg transition-all duration-200 hover:scale-105"
|
|
style={{
|
|
backgroundColor: colors.brightBlack,
|
|
color: colors.foreground
|
|
}}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="w-full h-2 rounded-full overflow-hidden" style={{ backgroundColor: colors.brightBlack }}>
|
|
<div
|
|
className="h-full rounded-full transition-all duration-300 ease-out"
|
|
style={{
|
|
width: `${serverCreationProgress.progress}%`,
|
|
backgroundColor: colors.blue
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Progress Percentage */}
|
|
<div className="flex items-center justify-between mt-2">
|
|
<span className="text-xs" style={{ color: colors.foreground }}>
|
|
{serverCreationProgress.phase}
|
|
</span>
|
|
<span className="text-xs font-medium" style={{ color: colors.blue }}>
|
|
{serverCreationProgress.progress.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{show2FASetup && createPortal(
|
|
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[9999]">
|
|
<div className="rounded-2xl w-full max-w-md" style={{ backgroundColor: colors.backgroundSecondary }}>
|
|
<div className="p-6 border-b flex justify-between items-center" style={{ borderColor: colors.brightBlack }}>
|
|
<h3 className="text-lg font-semibold" style={{ color: colors.brightWhite }}>Enable Two-Factor Authentication</h3>
|
|
<button onClick={() => setShow2FASetup(false)} className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack, color: colors.foreground }}>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6">
|
|
<p className="text-sm text-center" style={{ color: colors.foreground }}>Scan the QR code with your authenticator app.</p>
|
|
<div className="flex justify-center my-4">
|
|
<img src={`data:image/png;base64,${twoFactorData.qr_code}`} alt="2FA QR Code" />
|
|
</div>
|
|
<p className="text-sm text-center" style={{ color: colors.foreground }}>Then enter the code from your app below.</p>
|
|
<div className="mt-4">
|
|
<input type="text" id="otp-code" className="w-full rounded-xl px-4 py-3 text-sm focus:outline-none transition-all duration-200 border" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack, color: colors.brightWhite }} placeholder="6-digit code" />
|
|
</div>
|
|
<div className="flex space-x-3 mt-6">
|
|
<button onClick={() => setShow2FASetup(false)} className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border" style={{ backgroundColor: colors.brightBlack, borderColor: colors.brightBlack, color: colors.brightWhite }}>
|
|
Cancel
|
|
</button>
|
|
<button onClick={() => {
|
|
const otpCode = document.getElementById('otp-code').value;
|
|
if (otpCode) {
|
|
sendDashboardMessage({ type: '2fa_enable', otp_code: otpCode });
|
|
}
|
|
}} className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border" style={{ backgroundColor: colors.green, borderColor: colors.green, color: colors.brightWhite }}>
|
|
Verify & Enable
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default DashboardPage;
|