Files
anchorhost-frontend/src/components/DashboardComponents.js
T

1295 lines
54 KiB
JavaScript

import React, { useState, useEffect, useRef, useId, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
CheckCircle, Search,
Info, RefreshCw, X, Globe, Shield, BarChart3, Settings,
Plus, Trash2, Link, ExternalLink, Unlink
} from "lucide-react";
import { colors as themeColors } from '../theme/colors';
import {
StatTile,
ActionButton,
DashboardEmptyState,
DashboardLoadingSpinner,
SettingsPanel,
SectionCard,
NumberSpinner,
Dropdown,
} from './dashboardPrimitives';
export const colors = themeColors;
// --- Helper Functions ---
export const parseServerDate = (dateString) => {
if (!dateString) return 'N/A';
try {
const datePart = dateString.split(' ')[0].split('T')[0];
if (datePart && /^\d{4}-\d{2}-\d{2}$/.test(datePart)) {
return datePart;
}
return 'Invalid Date';
} catch (error) {
console.error('Error parsing date:', dateString, error);
return 'Invalid Date';
}
};
export const formatCPU = (cpuStr) => {
if (!cpuStr) return { value: 0, unit: 'm', percentage: 0 };
if (cpuStr.endsWith('m')) {
const millicores = parseInt(cpuStr) || 0;
return {
value: millicores,
unit: 'm',
percentage: Math.min((millicores / 1000) * 100, 100),
display: `${millicores}m`
};
}
const cores = parseFloat(cpuStr) || 0;
return {
value: cores,
unit: 'cores',
percentage: Math.min(cores * 100, 100),
display: `${cores} core${cores !== 1 ? 's' : ''}`
};
};
export const formatMemory = (memoryStr, totalMemoryGB = 1) => {
if (!memoryStr) return { value: 0, unit: 'Mi', percentage: 0 };
if (memoryStr.endsWith('Mi')) {
const mebibytes = parseInt(memoryStr) || 0;
const totalMebibytes = totalMemoryGB * 1024;
return {
value: mebibytes,
unit: 'Mi',
percentage: Math.min((mebibytes / totalMebibytes) * 100, 100),
display: `${(mebibytes / 1024).toFixed(1)} GB`
};
}
if (memoryStr.endsWith('Gi') || memoryStr.toLowerCase().includes('gb')) {
const gibibytes = parseFloat(memoryStr) || 0;
return {
value: gibibytes * 1024,
unit: 'Gi',
percentage: Math.min((gibibytes / totalMemoryGB) * 100, 100),
display: `${gibibytes.toFixed(1)} GB`
};
}
if (memoryStr.toLowerCase().includes('mb')) {
const mebibytes = parseFloat(memoryStr) || 0;
const totalMebibytes = totalMemoryGB * 1024;
return {
value: mebibytes,
unit: 'Mi',
percentage: Math.min((mebibytes / totalMebibytes) * 100, 100),
display: `${(mebibytes / 1024).toFixed(1)} GB`
};
}
return { value: 0, unit: 'Mi', percentage: 0, display: '0 GB' };
};
export const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
export const getServerStatusInfo = (status) => {
switch (status) {
case 'Running':
return { label: 'Running', color: colors.green, bgColor: `${colors.green}20` };
case 'Stopped':
return { label: 'Stopped', color: colors.red, bgColor: `${colors.red}20` };
case 'Starting':
return { label: 'Starting', color: colors.yellow, bgColor: `${colors.yellow}20` };
case 'Stopping':
return { label: 'Stopping', color: colors.yellow, bgColor: `${colors.yellow}20` };
case 'Restarting':
return { label: 'Restarting', color: colors.yellow, bgColor: `${colors.yellow}20` };
case 'Error':
return { label: 'Error', color: colors.red, bgColor: `${colors.red}20` };
default:
return {
label: status && String(status).length ? String(status) : 'Unknown',
color: colors.foreground,
bgColor: colors.brightBlack,
};
}
};
export const minecraftVersions = [
{ series: '26 Series', versions: ['26.2', '26.1.2', '26.1.1', '26.1'] },
{ series: '1.21 Series (Tricky Trials / Mounts of Mayhem)', versions: ['1.21.11', '1.21.10', '1.21.9', '1.21.8', '1.21.7', '1.21.6', '1.21.5', '1.21.4', '1.21.3', '1.21.2', '1.21.1', '1.21'] },
{ series: '1.20 Series (Trails & Tales)', versions: ['1.20.6', '1.20.5', '1.20.4', '1.20.3', '1.20.2', '1.20.1', '1.20'] },
{ series: '1.19 Series (The Wild Update)', versions: ['1.19.4', '1.19.3', '1.19.2', '1.19.1', '1.19'] },
{ series: '1.18 Series (Caves & Cliffs Part II)', versions: ['1.18.2', '1.18.1', '1.18'] },
{ series: '1.17 Series (Caves & Cliffs Part I)', versions: ['1.17.1', '1.17'] },
{ series: '1.16 Series (Nether Update)', versions: ['1.16.5', '1.16.4', '1.16.3', '1.16.2', '1.16.1', '1.16'] },
{ series: '1.15 Series (Buzzy Bees)', versions: ['1.15.2', '1.15.1', '1.15'] },
{ series: '1.14 Series (Village & Pillage)', versions: ['1.14.4', '1.14.3', '1.14.2', '1.14.1', '1.14'] },
{ series: '1.13 Series (Update Aquatic)', versions: ['1.13.2', '1.13.1', '1.13'] },
{ series: '1.12 Series (World of Color Update)', versions: ['1.12.2', '1.12.1', '1.12'] },
{ series: '1.11 Series (Exploration Update)', versions: ['1.11.2', '1.11.1', '1.11'] },
{ series: '1.10 Series (Frostburn Update)', versions: ['1.10.2', '1.10.1', '1.10'] },
{ series: '1.9 Series (Combat Update)', versions: ['1.9.4', '1.9.3', '1.9.2', '1.9.1', '1.9'] },
{ series: '1.8 Series (Bountiful Update)', versions: ['1.8.9', '1.8.8', '1.8.7', '1.8.6', '1.8.5', '1.8.4', '1.8.3', '1.8.2', '1.8.1', '1.8'] },
{ series: '1.7 Series (The Update that Changed the World)', versions: ['1.7.10', '1.7.9', '1.7.8', '1.7.7', '1.7.6', '1.7.5', '1.7.4', '1.7.2'] },
{ series: '1.6 Series (Horse Update)', versions: ['1.6.4', '1.6.2', '1.6.1'] },
{ series: '1.5 Series (Redstone Update)', versions: ['1.5.2', '1.5.1', '1.5'] },
{ series: '1.4 Series (Pretty Scary Update)', versions: ['1.4.7', '1.4.6', '1.4.5', '1.4.4', '1.4.2'] },
{ series: '1.3 Series (Villager Trading Update)', versions: ['1.3.2', '1.3.1'] },
{ series: '1.2 Series (Faithful Update)', versions: ['1.2.5', '1.2.4', '1.2.3', '1.2.2', '1.2.1'] },
{ series: '1.1 Series (Spawn Egg Update)', versions: ['1.1'] },
{ series: '1.0 Series (Adventure Update)', versions: ['1.0.1', '1.0.0'] }
];
export const processMetrics = (metrics) => {
if (!metrics) return { cpu: { display: '0m', value: 0 }, memory: { display: '0 Mi', value: 0 }, cpu_percentage: 0, memory_percentage: 0 };
return {
cpu: formatCPU(metrics.cpu),
memory: formatMemory(metrics.memory),
cpu_percentage: metrics.cpu_percentage || 0,
memory_percentage: metrics.memory_percentage || 0
};
};
export const parseCpuValue = (cpuStr) => {
if (!cpuStr) return 1;
const match = cpuStr.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[1]) : 1;
};
export const parseRamValue = (ramStr) => {
if (!ramStr) return 1;
const match = ramStr.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[1]) : 1;
};
export const parseStorageValue = (storageStr) => {
if (!storageStr) return 10;
const match = storageStr.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[1]) : 10;
};
// --- Components ---
export const PortTooltip = ({ portTooltip }) => {
return (
<div
className="ah-tooltip ah-tooltip-detail absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 max-w-[min(16rem,calc(100vw-2rem))] p-3.5 text-left"
role="tooltip"
>
<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>
);
};
export const UnifiedTooltip = ({
children,
content,
show = true,
placement = 'top',
id: idProp,
multiline = true,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const elementRef = useRef(null);
const hideTimeoutRef = useRef(null);
const reactId = useId();
const tooltipId = idProp || `dash-tip-${reactId.replace(/:/g, '')}`;
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
if (!show || !content) return children;
const updatePosition = () => {
if (elementRef.current) {
const rect = elementRef.current.getBoundingClientRect();
setPosition({
top: rect.top,
left: rect.left + rect.width / 2,
bottom: rect.bottom,
});
}
};
const showTip = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
updatePosition();
setIsVisible(true);
};
const hideTip = () => {
hideTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
hideTimeoutRef.current = null;
}, 120);
};
const handleMouseEnter = () => showTip();
const handleMouseLeave = () => hideTip();
const handleFocus = (e) => {
if (elementRef.current?.contains(e.target)) showTip();
};
const handleBlur = (e) => {
if (!elementRef.current?.contains(e.relatedTarget)) hideTip();
};
const getTransform = () =>
placement === 'bottom' ? 'translate(-50%, 10px)' : 'translate(-50%, calc(-100% - 10px))';
const getTopPosition = () =>
placement === 'bottom' ? `${position.bottom}px` : `${position.top}px`;
const wrapClass = multiline
? 'max-w-xs text-left leading-relaxed whitespace-normal break-words'
: 'whitespace-nowrap';
const tooltipContent = isVisible ? (
<div
id={tooltipId}
role="tooltip"
className={`ah-tooltip fixed z-[12000] px-3.5 py-2.5 text-sm pointer-events-none ${wrapClass}`}
style={{
top: getTopPosition(),
left: `${position.left}px`,
transform: getTransform(),
}}
>
{content}
</div>
) : null;
return (
<>
<div
ref={elementRef}
className="inline-flex max-w-full"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
>
{children}
</div>
{tooltipContent && createPortal(tooltipContent, document.body)}
</>
);
};
export const CustomTooltip = ({ children, content, show, ...rest }) => (
<UnifiedTooltip content={content} show={show} {...rest}>
{children}
</UnifiedTooltip>
);
export const ResourceGraph = ({ data, color, title, unit, maxValue, currentValue, percentage, serverStatus }) => {
// Ensure data is valid
const validData = Array.isArray(data) ? data : [];
// Fill missing data points to ensure graph spans full width if less than 60 points
const filledData = [...validData];
const maxPoints = 60;
if (filledData.length < maxPoints) {
const missing = maxPoints - filledData.length;
const filler = new Array(missing).fill({ value: 0, time: '', percentage: 0 });
filledData.unshift(...filler);
}
// Calculate points for SVG path
const pointsList = filledData.map((d, i) => {
const x = (i / (maxPoints - 1)) * 100;
const normalizedValue = Math.min(d.value, maxValue);
const y = 100 - ((normalizedValue / (maxValue || 1)) * 100);
return { x, y };
});
const pointsStr = pointsList.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ');
// Ensure the path starts at (0,100), goes to the points, and ends at (100,100) then closes
const areaPath = `M 0,100 L ${pointsList.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' L ')} L 100,100 Z`;
return (
<div className="rounded-2xl p-6 border border-dash-border bg-brightBlack/20 backdrop-blur-sm w-full transition-all duration-300 hover:scale-[1.01] hover:bg-brightBlack/30 shadow-xl shadow-black/20"
style={{ borderColor: `${color}20` }}>
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<div className="relative">
<div className="w-3 h-3 rounded-full shadow-[0_0_10px_rgba(255,255,255,0.2)]" style={{ backgroundColor: serverStatus === 'Running' ? color : colors.foreground }} />
{serverStatus === 'Running' && (
<div className="absolute inset-0 w-3 h-3 rounded-full blur-[6px] animate-pulse" style={{ backgroundColor: color }} />
)}
</div>
<span className="text-sm font-bold uppercase tracking-widest opacity-60" style={{ color: colors.foreground }}>{title}</span>
</div>
<div className="text-right">
<div className="text-3xl font-black tracking-tighter" style={{ color: colors.brightWhite }}>
{currentValue}
</div>
<div className="text-[10px] font-bold opacity-40 uppercase tracking-widest mt-1" style={{ color: colors.foreground }}>
{percentage}% of {unit === 'Mi' ? formatFileSize(maxValue * 1024 * 1024) : `${maxValue / 1000} cores`}
</div>
</div>
</div>
<div className="h-36 w-full relative overflow-hidden rounded-2xl border border-white/5 shadow-inner transition-all duration-500 bg-black/40 group/graph">
{serverStatus === 'Running' ? (
<svg className="w-full h-full" preserveAspectRatio="none" viewBox="0 0 100 100" style={{ overflow: 'visible' }}>
<defs>
<linearGradient id={`gradient-${String(title || 'graph').replace(/\s+/g, '-')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.6" />
<stop offset="100%" stopColor={color} stopOpacity="0.05" />
</linearGradient>
<filter id="glow-graph" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="2.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<path
d={areaPath}
fill={`url(#gradient-${String(title || 'graph').replace(/\s+/g, '-')})`}
className="transition-all duration-700"
/>
{/* Glow effect line */}
<path
d={`M ${pointsList[0].x.toFixed(2)},${pointsList[0].y.toFixed(2)} L ${pointsList.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' L ')}`}
fill="none"
stroke={color}
strokeWidth="6"
strokeOpacity="0.2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
filter="url(#glow-graph)"
/>
{/* Main line */}
<polyline
points={pointsStr}
fill="none"
stroke={color}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-2">
<RefreshCw className="w-6 h-6 opacity-10" />
<span className="text-[10px] font-bold uppercase tracking-widest opacity-20" style={{ color: colors.foreground }}>Offline</span>
</div>
)}
</div>
</div>
);
};
export const MinecraftSettings = ({ server, settings, onSave, isLoading }) => {
const [localSettings, setLocalSettings] = useState(settings || {});
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
if (settings) {
setLocalSettings(settings);
}
}, [settings]);
const handleChange = (key, value) => {
setLocalSettings(prev => ({
...prev,
[key]: value
}));
};
const handleToggle = (key) => {
const currentValue = localSettings[key] === 'true';
handleChange(key, (!currentValue).toString());
};
const handleSave = () => {
onSave(localSettings);
};
const settingGroups = [
{
title: 'General & World',
icon: Globe,
settings: [
{ key: 'level-name', label: 'World Name', type: 'text', description: 'The name of your world folder' },
{ key: 'level-seed', label: 'Level Seed', type: 'text', description: 'Seed for world generation' },
{ key: 'gamemode', label: 'Default Gamemode', type: 'select', options: [
{ label: 'Survival', value: 'survival' },
{ label: 'Creative', value: 'creative' },
{ label: 'Adventure', value: 'adventure' },
{ label: 'Spectator', value: 'spectator' }
]},
{ key: 'difficulty', label: 'Difficulty', type: 'select', options: [
{ label: 'Peaceful', value: 'peaceful' },
{ label: 'Easy', value: 'easy' },
{ label: 'Normal', value: 'normal' },
{ label: 'Hard', value: 'hard' }
]},
{ key: 'hardcore', label: 'Hardcore Mode', type: 'toggle', description: 'Players are banned on death' },
{ key: 'pvp', label: 'PVP Enabled', type: 'toggle' },
{ key: 'allow-flight', label: 'Allow Flight', type: 'toggle', description: 'Allow players to fly (requires fly mod or creative)' },
{ key: 'allow-nether', label: 'Allow Nether', type: 'toggle' },
]
},
{
title: 'Security & Access',
icon: Shield,
settings: [
{ key: 'online-mode', label: 'Online Mode (Premium)', type: 'toggle', description: 'Disable to allow non-premium/cracked players' },
{ key: 'white-list', label: 'Whitelist', type: 'toggle', description: 'Only allowed players can join' },
{ key: 'enforce-whitelist', label: 'Enforce Whitelist', type: 'toggle' },
{ key: 'spawn-protection', label: 'Spawn Protection', type: 'number', description: 'Radius of spawn protection in blocks' },
{ key: 'op-permission-level', label: 'OP Permission Level', type: 'select', options: [
{ label: 'Level 1 - Bypass Spawn Protection', value: '1' },
{ label: 'Level 2 - Basic Commands', value: '2' },
{ label: 'Level 3 - Management Commands', value: '3' },
{ label: 'Level 4 - All Commands', value: '4' }
]},
{ key: 'enable-command-block', label: 'Command Blocks', type: 'toggle' },
]
},
{
title: 'Server Information',
icon: Info,
settings: [
{ key: 'motd', label: 'MOTD', type: 'text', description: 'Message of the Day (shown in server list)' },
{ key: 'max-players', label: 'Max Players', type: 'number' },
{ key: 'view-distance', label: 'View Distance', type: 'number', description: 'Chunks sent to players (3-32)' },
{ key: 'simulation-distance', label: 'Simulation Distance', type: 'number', description: 'Chunks to tick (3-32)' },
]
},
{
title: 'Technical & Performance',
icon: BarChart3,
settings: [
{ key: 'sync-chunk-writes', label: 'Sync Chunk Writes', type: 'toggle' },
{ key: 'max-tick-time', label: 'Max Tick Time', type: 'number', description: 'Max ms before watchdog kills server (-1 to disable)' },
{ key: 'network-compression-threshold', label: 'Network Compression', type: 'number' },
{ key: 'entity-broadcast-range-percentage', label: 'Entity Range %', type: 'number' },
{ key: 'enable-query', label: 'Enable Query', type: 'toggle' },
{ key: 'enable-rcon', label: 'Enable RCON', type: 'toggle' },
]
}
];
// Map all current settings not in specific groups to an "Other" category if they aren't empty
const knownKeys = new Set(settingGroups.flatMap(g => g.settings.map(s => s.key)));
const otherSettings = Object.entries(localSettings)
.filter(([key]) => !knownKeys.has(key) && key.trim() !== '')
.map(([key, value]) => ({
key,
label: key.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
type: (value === 'true' || value === 'false') ? 'toggle' : (isNaN(value) || value === '' ? 'text' : 'number'),
description: 'Advanced property from server.properties'
}));
if (otherSettings.length > 0) {
settingGroups.push({
title: 'Other Settings',
icon: Settings,
settings: otherSettings
});
}
const filteredGroups = settingGroups.map(group => ({
...group,
settings: group.settings.filter(s =>
s.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.key.toLowerCase().includes(searchTerm.toLowerCase()) ||
(s.description && s.description.toLowerCase().includes(searchTerm.toLowerCase()))
)
})).filter(group => group.settings.length > 0);
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Header with search and save */}
<div className="pb-4">
<SectionCard title="Quick Settings" icon={Settings} color={colors.blue}>
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6">
<div>
<p className="text-sm font-medium opacity-60">Direct control over server.properties</p>
</div>
<div className="flex flex-col sm:flex-row w-full lg:w-auto items-stretch sm:items-center gap-4">
<div className="relative flex-1 sm:w-80">
<input
type="text"
placeholder="Search settings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-black/40 border border-dash-border rounded-xl px-4 py-3 pl-11 text-sm outline-none focus:border-primary/50 focus:ring-4 focus:ring-primary/10 transition-all font-bold"
style={{ color: colors.brightWhite }}
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 opacity-40">
<Search className="w-5 h-5" />
</div>
</div>
<ActionButton
onClick={handleSave}
disabled={isLoading}
variant="primary"
className="!px-6 !py-2.5 !min-h-0 sm:!min-h-0 !text-[11px] uppercase tracking-widest shadow-xl"
>
{isLoading ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
<span>Save Configuration</span>
</ActionButton> </div>
</div>
</SectionCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 pb-8">
{filteredGroups.map((group, gIdx) => (
<SectionCard key={gIdx} title={group.title} icon={group.icon} color={colors.blue}>
<div className="space-y-4">
{group.settings.map((s) => (
<div key={s.key}
className="group flex flex-col space-y-4 p-5 rounded-2xl border border-dash-border transition-all duration-200 bg-white/[0.01] hover:bg-white/[0.03] hover:border-white/10">
<div className="flex justify-between items-start gap-4">
<div className="flex flex-col space-y-1 min-w-0">
<label className="text-sm font-black tracking-tight" style={{ color: colors.brightWhite }}>
{s.label}
</label>
<code className="text-[9px] px-1.5 py-0.5 rounded bg-black/40 text-primary/60 font-mono w-fit border border-white/5">{s.key}</code>
</div>
<div className="flex-shrink-0">
{s.type === 'toggle' && (
<button
onClick={() => handleToggle(s.key)}
className={`w-12 h-6 rounded-full transition-all duration-300 relative border ${
localSettings[s.key] === 'true' ? 'border-primary bg-primary shadow-[0_0_10px_rgba(90,124,167,0.4)]' : 'border-white/10 bg-black/40'
}`}
>
<div className={`absolute top-0.5 w-4 h-4 rounded-full transition-all duration-500 shadow-md ${
localSettings[s.key] === 'true' ? 'translate-x-6 bg-brightWhite' : 'translate-x-0.5 bg-gray-400'
}`} />
</button>
)}
{s.type === 'select' && (
<div className="w-56">
<Dropdown
value={localSettings[s.key] || ''}
options={s.options}
onChange={(val) => handleChange(s.key, val)}
placeholder="Select..."
/>
</div>
)}
</div>
</div>
{s.type === 'text' && (
<input
type="text"
value={localSettings[s.key] || ''}
onChange={(e) => handleChange(s.key, e.target.value)}
className="bg-black/40 border border-dash-border rounded-xl px-4 py-3 text-sm outline-none focus:border-primary/50 focus:bg-black/60 w-full transition-all font-bold shadow-inner"
style={{ color: colors.brightWhite }}
/>
)}
{s.type === 'number' && (
<NumberSpinner
value={localSettings[s.key] || '0'}
onChange={(val) => handleChange(s.key, val)}
className="w-full h-12"
/>
)}
{s.description && (
<p className="text-[11px] opacity-40 font-medium leading-relaxed border-l-2 border-primary/20 pl-3">{s.description}</p>
)}
</div>
))}
</div>
</SectionCard>
))}
</div>
{filteredGroups.length === 0 && (
<DashboardEmptyState
icon={Search}
title="No settings found"
description={`We couldn't find any settings matching "${searchTerm}". Try a different search term.`}
action={
<ActionButton variant="secondary" onClick={() => setSearchTerm('')}>
Clear Search
</ActionButton>
}
/>
)}
<div className="bg-primary/5 border border-primary/10 p-6 rounded-2xl flex items-start space-x-5 mb-8 backdrop-blur-sm">
<div className="p-2.5 bg-primary/10 rounded-xl border border-primary/20 shadow-lg shadow-primary/5">
<RefreshCw className="w-5 h-5 text-primary" />
</div>
<div>
<h5 className="font-black text-sm mb-1 uppercase tracking-wider" style={{ color: colors.brightWhite }}>Restart Required</h5>
<p className="text-xs text-primary/70 leading-relaxed font-medium">
Most changes to <code className="bg-black/40 px-1.5 py-0.5 rounded text-[10px] border border-white/5 font-mono">server.properties</code> require a server restart to take effect.
Use the <span className="font-bold text-primary">Restart</span> action in the header after saving your changes.
</p>
</div>
</div>
</div>
);
};
export const DomainManager = ({ userId, username, servers, sendMessage, showAlert, showConfirm }) => {
const [domains, setDomains] = useState([]);
const [customDomains, setCustomDomains] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [newDomainName, setNewDomainName] = useState('');
const [selectedServerForLink, setSelectedServerForLink] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const [selectedDomainForLink, setSelectedDomainForLink] = useState(null);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [actionInProgress, setActionInProgress] = useState(false);
// Custom domains state
const [showCustomDomainModal, setShowCustomDomainModal] = useState(false);
const [customDomainName, setCustomDomainName] = useState('');
const [verifyingCustomDomain, setVerifyingCustomDomain] = useState(false);
const [customDomainVerificationResult, setCustomDomainVerificationResult] = useState(null);
const [verificationStep, setVerificationStep] = useState('idle');
const linkableServers = useMemo(
() => servers.filter((s) => ['website', 'vps'].includes(s.type)),
[servers]
);
const serverLinkOptions = useMemo(
() => [
{ value: '', label: 'No server (link later)' },
...linkableServers.map((s) => ({ value: s.id, label: s.name })),
],
[linkableServers]
);
// Clear messages after timeout
useEffect(() => {
if (errorMessage || successMessage) {
const timer = setTimeout(() => {
setErrorMessage('');
setSuccessMessage('');
}, 5000);
return () => clearTimeout(timer);
}
}, [errorMessage, successMessage]);
// Load domains function
const loadDomains = useCallback(() => {
setIsLoading(true);
setErrorMessage('');
sendMessage({
type: 'list_domains',
includeServerless: true
});
}, [sendMessage]);
const loadCustomDomains = useCallback(() => {
sendMessage({
type: 'list_custom_domains'
});
}, [sendMessage]);
// Initial load and periodic refresh
useEffect(() => {
loadDomains();
loadCustomDomains();
const interval = setInterval(() => {
loadDomains();
loadCustomDomains();
}, 30000);
return () => clearInterval(interval);
}, [loadDomains, loadCustomDomains]);
// WebSocket message handler
useEffect(() => {
const handleMessage = (data) => {
switch (data.type) {
case 'domains_list':
setDomains(data.domains || []);
setIsLoading(false);
break;
case 'custom_domains_list':
setCustomDomains(data.domains || []);
break;
case 'domain_created':
setSuccessMessage(data.message || 'Domain created successfully!');
setShowCreateModal(false);
setNewDomainName('');
setActionInProgress(false);
loadDomains();
break;
case 'custom_domain_created':
setSuccessMessage(data.message || 'Custom domain added successfully!');
setShowCustomDomainModal(false);
setCustomDomainName('');
setCustomDomainVerificationResult(null);
setVerificationStep('idle');
setActionInProgress(false);
loadCustomDomains();
break;
case 'custom_domain_verified':
if (data.verified) {
setCustomDomainVerificationResult({
success: true,
message: 'Domain verified successfully!',
ipAddress: data.ip_address
});
setVerificationStep('success');
} else {
setCustomDomainVerificationResult({
success: false,
message: `Verification failed. Please update DNS records.`,
currentIp: data.current_ip
});
setVerificationStep('error');
}
setVerifyingCustomDomain(false);
break;
case 'domain_deleted':
setSuccessMessage(data.message || 'Domain deleted successfully!');
setActionInProgress(false);
loadDomains();
break;
case 'custom_domain_deleted':
setSuccessMessage(data.message || 'Custom domain deleted successfully!');
setActionInProgress(false);
loadCustomDomains();
break;
case 'domain_linked':
setSuccessMessage(data.message || 'Domain linked successfully!');
setShowLinkModal(false);
setSelectedDomainForLink(null);
setActionInProgress(false);
loadDomains();
loadCustomDomains();
break;
case 'domain_unlinked':
setSuccessMessage(data.message || 'Domain unlinked successfully!');
setActionInProgress(false);
loadDomains();
loadCustomDomains();
break;
case 'error':
if (data.error.includes('domain') || data.error.includes('Domain')) {
setErrorMessage(data.error || 'An error occurred');
setIsLoading(false);
setActionInProgress(false);
}
break;
default:
break;
}
};
window.__domainManagerHandler = handleMessage;
return () => {
delete window.__domainManagerHandler;
};
}, [loadDomains, loadCustomDomains]);
const createDomain = () => {
if (!newDomainName.trim()) {
setErrorMessage('Please enter a domain name');
return;
}
// Validate domain name format
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
if (!domainRegex.test(newDomainName)) {
setErrorMessage('Domain name can only contain letters, numbers, and hyphens. Cannot start or end with hyphen.');
return;
}
if (newDomainName.length > 63) {
setErrorMessage('Domain name must be 63 characters or less');
return;
}
setActionInProgress(true);
setErrorMessage('');
setSuccessMessage('');
sendMessage({
type: 'create_domain',
subdomain: newDomainName.trim().toLowerCase(),
serverId: selectedServerForLink || null
});
};
const deleteDomain = (domainId, isCustom = false) => {
showConfirm('Delete Domain?', 'Are you sure? This cannot be undone.', () => {
setActionInProgress(true);
sendMessage({
type: isCustom ? 'delete_custom_domain' : 'delete_domain',
domainId: domainId
});
});
};
const openLinkModal = (domain, isCustom = false) => {
setSelectedDomainForLink({ ...domain, isCustom });
setSelectedServerForLink(domain.server_id || '');
setShowLinkModal(true);
};
const confirmLinkDomain = () => {
if (!selectedDomainForLink) return;
if (!selectedServerForLink) {
setErrorMessage('Please select a server to link');
return;
}
setActionInProgress(true);
setErrorMessage('');
sendMessage({
type: 'link_domain',
domainId: selectedDomainForLink.id,
serverId: selectedServerForLink,
});
};
const unlinkDomain = (domainId) => {
showConfirm('Unlink Domain?', 'Remove the server association for this domain?', () => {
setActionInProgress(true);
sendMessage({ type: 'unlink_domain', domainId });
});
};
const getDomainStatus = (domain) => {
if (!domain.server_id) return { label: 'Unlinked', color: colors.yellow, bgColor: `${colors.yellow}20` };
const linkedServer = servers.find(s => s.id === domain.server_id);
if (!linkedServer) return { label: 'Server Not Found', color: colors.red, bgColor: `${colors.red}20` };
return { label: `Linked: ${linkedServer.name}`, color: colors.green, bgColor: `${colors.green}20` };
};
const NODE_IP = '85.237.179.121';
return (
<div className="space-y-6">
{/* Summary Stats */}
{!isLoading && (domains.length > 0 || customDomains.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatTile
icon={Globe}
label="Total Domains"
value={domains.length + customDomains.length}
accentColor={colors.blue}
/>
<StatTile
icon={Shield}
label="Linked Domains"
value={[...domains, ...customDomains].filter(d => d.server_id).length}
accentColor={colors.green}
/>
<StatTile
icon={Plus}
label="Available Slots"
value="Unlimited"
accentColor={colors.cyan}
/>
</div>
)}
{/* Delegated Domains */}
<SectionCard
title="Delegated Domains"
icon={Globe}
color={colors.blue}
actions={
!isLoading && domains.length > 0 && (
<ActionButton onClick={() => setShowCreateModal(true)} variant="primary" className="!px-4 !py-2 !min-h-0 sm:!min-h-0">
<Plus className="w-4 h-4 shrink-0" aria-hidden />
<span>Create Domain</span>
</ActionButton>
)
}
>
{isLoading ? (
<DashboardLoadingSpinner label="Loading domains..." />
) : domains.length === 0 ? (
<DashboardEmptyState
icon={Globe}
title="No domains found"
description="Create your first subdomain to point to your servers."
onClick={() => setShowCreateModal(true)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{domains.map(domain => {
const status = getDomainStatus(domain);
return (
<div key={domain.id} className="group relative p-5 rounded-2xl border transition-all duration-300 bg-black/40 hover:bg-brightBlack/20 hover:border-primary/30 shadow-lg shadow-black/10 overflow-hidden"
style={{ borderColor: colors.brightBlack }}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20 shrink-0">
<Globe className="w-4 h-4 text-primary" />
</div>
<p className="font-black text-lg truncate text-dash-text" style={{ color: colors.brightWhite }}>{domain.full_domain}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{domain.server_id ? (
<UnifiedTooltip content="Unlink server">
<ActionButton
onClick={() => unlinkDomain(domain.id)}
variant="secondary"
className="!px-3 !py-2 !min-h-0"
>
<Unlink className="w-4 h-4" />
</ActionButton>
</UnifiedTooltip>
) : (
<UnifiedTooltip content="Link to server">
<ActionButton
onClick={() => openLinkModal(domain)}
variant="primary"
className="!px-3 !py-2 !min-h-0"
>
<Link className="w-4 h-4" />
</ActionButton>
</UnifiedTooltip>
)}
<UnifiedTooltip content="Delete domain">
<ActionButton onClick={() => deleteDomain(domain.id)} variant="danger" className="!px-3 !py-2 !min-h-0">
<Trash2 className="w-4 h-4" />
</ActionButton>
</UnifiedTooltip>
</div>
</div>
<div className="mt-auto pt-4 border-t border-white/5 flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-full"
style={{ backgroundColor: status.bgColor, color: status.color }}>
{status.label}
</span>
<div className="flex items-center gap-2 opacity-40 text-[10px] font-bold uppercase tracking-tighter">
<Shield className="w-3 h-3" />
<span>Protected</span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</SectionCard>
{/* Custom Domains Section */}
<SectionCard
title="Custom Domains"
icon={ExternalLink}
color={colors.cyan}
actions={
!isLoading && customDomains.length > 0 && (
<ActionButton onClick={() => setShowCustomDomainModal(true)} variant="primary" className="!px-4 !py-2 !min-h-0 sm:!min-h-0">
<Plus className="w-4 h-4 shrink-0" aria-hidden />
<span>Add Custom</span>
</ActionButton>
)
}
>
{customDomains.length === 0 ? (
<DashboardEmptyState
icon={Globe}
title="No custom domains"
description="Add your own domain and point it to our infrastructure."
onClick={() => setShowCustomDomainModal(true)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{customDomains.map(domain => {
const status = getDomainStatus(domain);
return (
<div key={domain.id} className="group relative p-5 rounded-2xl border transition-all duration-300 bg-black/40 hover:bg-brightBlack/20 hover:border-primary/30 shadow-lg shadow-black/10 overflow-hidden"
style={{ borderColor: colors.brightBlack }}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-4 gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 shrink-0">
<ExternalLink className="w-4 h-4 text-cyan-400" />
</div>
<p className="font-black text-lg truncate text-dash-text" style={{ color: colors.brightWhite }}>{domain.domain_name}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{domain.server_id ? (
<UnifiedTooltip content="Unlink server">
<ActionButton
onClick={() => unlinkDomain(domain.id)}
variant="secondary"
className="!px-3 !py-2 !min-h-0"
>
<Unlink className="w-4 h-4" />
</ActionButton>
</UnifiedTooltip>
) : (
<UnifiedTooltip content="Link to server">
<ActionButton
onClick={() => openLinkModal(domain, true)}
variant="primary"
className="!px-3 !py-2 !min-h-0"
>
<Link className="w-4 h-4" />
</ActionButton>
</UnifiedTooltip>
)}
<UnifiedTooltip content="Delete domain">
<ActionButton onClick={() => deleteDomain(domain.id, true)} variant="danger" className="!px-3 !py-2 !min-h-0">
<Trash2 className="w-4 h-4" />
</ActionButton>
</UnifiedTooltip>
</div>
</div>
<div className="mt-auto pt-4 border-t border-white/5 flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1 rounded-full"
style={{ backgroundColor: status.bgColor, color: status.color }}>
{status.label}
</span>
<div className="flex items-center gap-2 opacity-40 text-[10px] font-bold uppercase tracking-tighter">
<CheckCircle className="w-3 h-3 text-cyan-400" />
<span>Verified</span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</SectionCard>
{/* Create Delegated Domain Modal */}
{showCreateModal && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md bg-dash-surfaceElevated border border-dash-border rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-dash-border flex items-center justify-between bg-primary/5">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-primary/20">
<Globe className="w-5 h-5 text-primary" />
</div>
<h3 className="text-xl font-black tracking-tight text-dash-text" style={{ color: colors.brightWhite }}>New Subdomain</h3>
</div>
<button onClick={() => setShowCreateModal(false)} className="p-2 rounded-xl hover:bg-white/5 transition-colors">
<X className="w-5 h-5 opacity-40" />
</button>
</div>
<div className="p-6 space-y-6">
<SettingsPanel title="Domain Details" description="Choose a unique subdomain. It will end with .anchorhost.eu">
<div className="space-y-4">
<div className="relative">
<input
type="text"
value={newDomainName}
onChange={(e) => setNewDomainName(e.target.value)}
placeholder="myserver"
className="w-full bg-black/40 border border-dash-border rounded-xl px-4 py-3 text-sm font-bold focus:outline-none focus:border-primary/50 transition-colors pr-32"
style={{ color: colors.brightWhite }}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-black opacity-30 uppercase tracking-widest pointer-events-none">
.anchorhost.eu
</div>
</div>
</div>
</SettingsPanel>
<SettingsPanel title="Link to Server (Optional)" description="Associate this subdomain with a website or VPS server">
<Dropdown
value={selectedServerForLink}
options={serverLinkOptions}
onChange={setSelectedServerForLink}
placeholder="Select server..."
/>
</SettingsPanel>
<div className="flex gap-3">
<ActionButton onClick={() => setShowCreateModal(false)} variant="secondary" className="flex-1">Cancel</ActionButton>
<ActionButton onClick={createDomain} variant="primary" className="flex-1" disabled={actionInProgress || !newDomainName.trim()}>
{actionInProgress ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Create Domain'}
</ActionButton>
</div>
</div>
</div>
</div>,
document.body
)}
{/* Add Custom Domain Modal */}
{showCustomDomainModal && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md bg-dash-surfaceElevated border border-dash-border rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-dash-border flex items-center justify-between bg-cyan-500/5">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-cyan-500/20">
<ExternalLink className="w-5 h-5 text-cyan-400" />
</div>
<h3 className="text-xl font-black tracking-tight text-dash-text" style={{ color: colors.brightWhite }}>Custom Domain</h3>
</div>
<button onClick={() => setShowCustomDomainModal(false)} className="p-2 rounded-xl hover:bg-white/5 transition-colors">
<X className="w-5 h-5 opacity-40" />
</button>
</div>
<div className="p-6 space-y-6">
<SettingsPanel title="Your Domain" description="Enter the full domain you want to link (e.g., play.example.com)">
<input
type="text"
value={customDomainName}
onChange={(e) => setCustomDomainName(e.target.value)}
placeholder="play.example.com"
className="w-full bg-black/40 border border-dash-border rounded-xl px-4 py-3 text-sm font-bold focus:outline-none focus:border-cyan-500/50 transition-colors"
style={{ color: colors.brightWhite }}
/>
</SettingsPanel>
<div className="p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
<p className="text-[10px] font-black uppercase tracking-widest text-cyan-400 mb-2">Instructions</p>
<p className="text-xs opacity-70 leading-relaxed" style={{ color: colors.foreground }}>
To link your domain, you must point its <span className="font-bold text-white">A record</span> to <span className="font-mono font-bold text-white">{NODE_IP}</span> before adding it here.
</p>
</div>
{customDomainVerificationResult && (
<div
className="p-4 rounded-xl border text-sm"
style={{
borderColor: customDomainVerificationResult.success ? `${colors.green}40` : `${colors.red}40`,
backgroundColor: customDomainVerificationResult.success ? `${colors.green}10` : `${colors.red}10`,
color: customDomainVerificationResult.success ? colors.brightGreen : colors.brightRed,
}}
>
{customDomainVerificationResult.message}
</div>
)}
{verificationStep !== 'idle' && verifyingCustomDomain && (
<p className="text-xs font-bold uppercase tracking-widest opacity-60" style={{ color: colors.foreground }}>
Verifying DNS records...
</p>
)}
<div className="flex gap-3">
<ActionButton onClick={() => setShowCustomDomainModal(false)} variant="secondary" className="flex-1">Cancel</ActionButton>
<ActionButton
onClick={() => {
setActionInProgress(true);
sendMessage({ type: 'create_custom_domain', domainName: customDomainName.trim().toLowerCase() });
}}
disabled={actionInProgress || !customDomainName.trim()}
variant="primary"
className="flex-1"
>
{actionInProgress ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Add Domain'}
</ActionButton>
</div>
</div>
</div>
</div>,
document.body
)}
{/* Link Domain to Server Modal */}
{showLinkModal && selectedDomainForLink && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md bg-dash-surfaceElevated border border-dash-border rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-dash-border flex items-center justify-between bg-primary/5">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-primary/20">
<Link className="w-5 h-5 text-primary" />
</div>
<h3 className="text-xl font-black tracking-tight text-dash-text" style={{ color: colors.brightWhite }}>
Link Domain
</h3>
</div>
<button onClick={() => { setShowLinkModal(false); setSelectedDomainForLink(null); }} className="p-2 rounded-xl hover:bg-white/5 transition-colors">
<X className="w-5 h-5 opacity-40" />
</button>
</div>
<div className="p-6 space-y-6">
<p className="text-sm font-bold" style={{ color: colors.foreground }}>
{selectedDomainForLink.full_domain || selectedDomainForLink.domain_name}
</p>
<SettingsPanel title="Target Server" description="Only website and VPS servers can be linked to a domain">
<Dropdown
value={selectedServerForLink}
options={linkableServers.length > 0 ? linkableServers.map((s) => ({ value: s.id, label: s.name })) : [{ value: '', label: 'No eligible servers' }]}
onChange={setSelectedServerForLink}
placeholder="Select server..."
/>
</SettingsPanel>
<div className="flex gap-3">
<ActionButton onClick={() => { setShowLinkModal(false); setSelectedDomainForLink(null); }} variant="secondary" className="flex-1">
Cancel
</ActionButton>
<ActionButton
onClick={confirmLinkDomain}
variant="primary"
className="flex-1"
disabled={actionInProgress || !selectedServerForLink || linkableServers.length === 0}
>
{actionInProgress ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Link Domain'}
</ActionButton>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
);
};
export {
DashboardViewShell,
PageHeaderBar,
StatTile,
ActionButton,
DashboardEmptyState,
DashboardLoadingSpinner,
SettingsPanel,
SectionCard,
Slider,
NumberSpinner,
Dropdown,
InlineDropdown
} from './dashboardPrimitives';