feat: refine server management workflows

This commit is contained in:
2026-08-13 02:19:26 +07:00
parent 3a09f0b571
commit bd473863f3
22 changed files with 2069 additions and 2208 deletions
+20 -145
View File
@@ -4,139 +4,20 @@ import type { CreateServerRequest } from "@minikura/api";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { ServerForm, type ServerFormData } from "@/components/server-form";
import { PageHeader, PageShell } from "@/components/page-layout";
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api-client";
const toDifficultyUppercase = (value: string): "PEACEFUL" | "EASY" | "NORMAL" | "HARD" => {
return value.toUpperCase() as "PEACEFUL" | "EASY" | "NORMAL" | "HARD";
};
const toModeUppercase = (value: string): "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR" => {
return value.toUpperCase() as "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR";
};
import { toCommonServerRequestFields } from "@/lib/server-form/mappings";
export default function CreateServerPage() {
const router = useRouter();
const handleSubmit = async (data: ServerFormData) => {
const filteredEnvVars = data.envVars.filter((ev) => ev.key && ev.value);
const envVariables: Record<string, string> = {};
if (data.allowFlight) envVariables.ALLOW_FLIGHT = String(data.allowFlight);
if (data.enableCommandBlock)
envVariables.ENABLE_COMMAND_BLOCK = String(data.enableCommandBlock);
if (data.spawnProtection) envVariables.SPAWN_PROTECTION = data.spawnProtection;
if (data.viewDistance) envVariables.VIEW_DISTANCE = data.viewDistance;
if (data.simulationDistance) envVariables.SIMULATION_DISTANCE = data.simulationDistance;
if (data.levelName) envVariables.LEVEL = data.levelName;
if (data.levelSeed) envVariables.SEED = data.levelSeed;
if (data.levelType) envVariables.LEVEL_TYPE = data.levelType;
if (data.generatorSettings) envVariables.GENERATOR_SETTINGS = data.generatorSettings;
if (data.hardcore) envVariables.HARDCORE = String(data.hardcore);
if (data.spawnAnimals !== undefined) envVariables.SPAWN_ANIMALS = String(data.spawnAnimals);
if (data.spawnMonsters !== undefined) envVariables.SPAWN_MONSTERS = String(data.spawnMonsters);
if (data.spawnNpcs !== undefined) envVariables.SPAWN_NPCS = String(data.spawnNpcs);
if (data.enableWhitelist) envVariables.ENABLE_WHITELIST = String(data.enableWhitelist);
if (data.whitelist) envVariables.WHITELIST = data.whitelist;
if (data.whitelistFile) envVariables.WHITELIST_FILE = data.whitelistFile;
if (data.ops) envVariables.OPS = data.ops;
if (data.opsFile) envVariables.OPS_FILE = data.opsFile;
if (data.jvmXxOpts) envVariables.JVM_XX_OPTS = data.jvmXxOpts;
if (data.jvmDdOpts) envVariables.JVM_DD_OPTS = data.jvmDdOpts;
if (data.enableJmx) envVariables.ENABLE_JMX = String(data.enableJmx);
if (data.resourcePack) envVariables.RESOURCE_PACK = data.resourcePack;
if (data.resourcePackSha1) envVariables.RESOURCE_PACK_SHA1 = data.resourcePackSha1;
if (data.resourcePackEnforce)
envVariables.RESOURCE_PACK_ENFORCE = String(data.resourcePackEnforce);
if (data.enableRcon !== undefined) envVariables.ENABLE_RCON = String(data.enableRcon);
if (data.rconPassword) envVariables.RCON_PASSWORD = data.rconPassword;
if (data.rconPort) envVariables.RCON_PORT = data.rconPort;
if (data.rconCmdsStartup) envVariables.RCON_CMDS_STARTUP = data.rconCmdsStartup;
if (data.rconCmdsOnConnect) envVariables.RCON_CMDS_ON_CONNECT = data.rconCmdsOnConnect;
if (data.rconCmdsFirstConnect) envVariables.RCON_CMDS_FIRST_CONNECT = data.rconCmdsFirstConnect;
if (data.rconCmdsOnDisconnect) envVariables.RCON_CMDS_ON_DISCONNECT = data.rconCmdsOnDisconnect;
if (data.rconCmdsLastDisconnect)
envVariables.RCON_CMDS_LAST_DISCONNECT = data.rconCmdsLastDisconnect;
if (data.enableQuery !== undefined) envVariables.ENABLE_QUERY = String(data.enableQuery);
if (data.queryPort) envVariables.QUERY_PORT = data.queryPort;
if (data.enableAutopause) envVariables.ENABLE_AUTOPAUSE = String(data.enableAutopause);
if (data.autopauseTimeoutEst) envVariables.AUTOPAUSE_TIMEOUT_EST = data.autopauseTimeoutEst;
if (data.autopauseTimeoutInit) envVariables.AUTOPAUSE_TIMEOUT_INIT = data.autopauseTimeoutInit;
if (data.autopauseTimeoutKn) envVariables.AUTOPAUSE_TIMEOUT_KN = data.autopauseTimeoutKn;
if (data.autopausePeriod) envVariables.AUTOPAUSE_PERIOD = data.autopausePeriod;
if (data.autopauseKnockInterface)
envVariables.AUTOPAUSE_KNOCK_INTERFACE = data.autopauseKnockInterface;
if (data.enableAutostop) envVariables.ENABLE_AUTOSTOP = String(data.enableAutostop);
if (data.autostopTimeoutEst) envVariables.AUTOSTOP_TIMEOUT_EST = data.autostopTimeoutEst;
if (data.autostopTimeoutInit) envVariables.AUTOSTOP_TIMEOUT_INIT = data.autostopTimeoutInit;
if (data.autostopPeriod) envVariables.AUTOSTOP_PERIOD = data.autostopPeriod;
if (data.plugins) envVariables.PLUGINS = data.plugins;
if (data.removeOldPlugins) envVariables.REMOVE_OLD_PLUGINS = String(data.removeOldPlugins);
if (data.spigetResources) envVariables.SPIGET_RESOURCES = data.spigetResources;
if (data.paperBuild) envVariables.PAPER_BUILD = data.paperBuild;
if (data.type === "CUSTOM" && data.customJarUrl) {
envVariables.CUSTOM_SERVER = data.customJarUrl;
envVariables.VERSION = "";
}
if (data.timezone) envVariables.TZ = data.timezone;
if (data.uid) envVariables.UID = data.uid;
if (data.gid) envVariables.GID = data.gid;
if (data.stopDuration) envVariables.STOP_DURATION = data.stopDuration;
if (data.serverIcon) envVariables.ICON = data.serverIcon;
envVariables.EULA = String(data.eula);
envVariables.TYPE = data.type;
if (data.type !== "CUSTOM" && data.version) {
envVariables.VERSION = data.version;
}
if (data.type === "CUSTOM" && !data.customJarUrl) {
throw new Error("Custom jar URL is required for custom servers");
}
for (const envVar of filteredEnvVars) {
envVariables[envVar.key] = envVar.value;
}
const payload: CreateServerRequest = {
id: data.id.trim(),
description: data.description.trim() || null,
listen_port: Number(data.listenPort),
type: "STATEFUL",
service_type: data.serviceType,
node_port: data.serviceType === "NODE_PORT" && data.nodePort ? Number(data.nodePort) : null,
env_variables: Object.entries(envVariables).map(([key, value]) => ({ key, value })),
memory: data.memoryLimit ? Number(data.memoryLimit) : undefined,
memory_request: data.memoryRequest ? Number(data.memoryRequest) : undefined,
cpu_request: data.cpuRequest || undefined,
cpu_limit: data.cpuLimit || undefined,
jar_type: data.type === "CUSTOM" ? "VANILLA" : data.type,
minecraft_version: data.type === "CUSTOM" ? undefined : data.version || "LATEST",
jvm_opts: data.jvmOpts || undefined,
use_aikar_flags: data.useAikarFlags || undefined,
use_meowice_flags: data.useMeowiceFlags || undefined,
difficulty: toDifficultyUppercase(data.difficulty),
game_mode: toModeUppercase(data.mode),
max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined,
pvp: data.pvp,
online_mode: data.onlineMode,
motd: data.motd,
level_seed: data.levelSeed,
level_type: data.levelType,
...toCommonServerRequestFields(data),
};
const response = await api.api.servers.post(payload);
@@ -158,35 +39,29 @@ export default function CreateServerPage() {
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<PageShell>
<PageHeader
eyebrow="Compute / Provision"
title="Create Server"
description="Define the runtime, world, resources, and network contract."
leading={
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard/servers")}>
<ArrowLeft className="h-5 w-5" />
<ArrowLeft className="size-5" />
</Button>
<div>
<h1 className="text-3xl font-bold">Create Minecraft Server</h1>
<p className="text-muted-foreground mt-1">
Configure your new Minecraft server with comprehensive settings
</p>
</div>
</div>
}
className="flex-row items-center justify-start"
/>
<Card>
<CardHeader>
<CardTitle>Server Configuration</CardTitle>
<CardDescription>
Complete configuration for itzg/minecraft-server Docker image with all environment
variables
</CardDescription>
</CardHeader>
<CardContent>
<SectionCard
title="Server Configuration"
description="Complete configuration for the itzg/minecraft-server workload."
>
<ServerForm
onSubmit={handleSubmit}
onCancel={() => router.push("/dashboard/servers")}
submitLabel="Create Server"
/>
</CardContent>
</Card>
</div>
</SectionCard>
</PageShell>
);
}
+31 -352
View File
@@ -4,11 +4,13 @@ import type { NormalServer, UpdateServerRequest } from "@minikura/api";
import { ArrowLeft } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { ServerForm, type ServerFormData, type ServerType } from "@/components/server-form";
import { ServerForm, type ServerFormData } from "@/components/server-form";
import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
import { toCommonServerRequestFields, toInitialServerFormData } from "@/lib/server-form/mappings";
export default function EditServerPage() {
const router = useRouter();
@@ -59,161 +61,10 @@ export default function EditServerPage() {
}
}, [serverId]);
const toServiceType = (value?: string | null): ServerFormData["serviceType"] => {
if (value === "NODE_PORT" || value === "LOAD_BALANCER") {
return value;
}
return "CLUSTER_IP";
};
const toDifficulty = (value?: string | null): ServerFormData["difficulty"] => {
if (value === "peaceful" || value === "easy" || value === "normal" || value === "hard") {
return value;
}
return "easy";
};
const toMode = (value?: string | null): ServerFormData["mode"] => {
if (value === "creative" || value === "adventure" || value === "spectator") {
return value;
}
return "survival";
};
const toDifficultyUppercase = (value: string): "PEACEFUL" | "EASY" | "NORMAL" | "HARD" => {
return value.toUpperCase() as "PEACEFUL" | "EASY" | "NORMAL" | "HARD";
};
const toModeUppercase = (value: string): "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR" => {
return value.toUpperCase() as "SURVIVAL" | "CREATIVE" | "ADVENTURE" | "SPECTATOR";
};
const parseEnvVariables = (envVars?: Array<{ key: string; value: string }>) => {
if (!envVars) return {};
const parsed: Record<string, string> = {};
for (const { key, value } of envVars) {
parsed[key] = value;
}
return parsed;
};
const handleSubmit = async (data: ServerFormData) => {
if (!serverData) return;
const filteredEnvVars = data.envVars.filter((ev) => ev.key && ev.value);
const envVariables: Record<string, string> = {};
if (data.allowFlight) envVariables.ALLOW_FLIGHT = String(data.allowFlight);
if (data.enableCommandBlock)
envVariables.ENABLE_COMMAND_BLOCK = String(data.enableCommandBlock);
if (data.spawnProtection) envVariables.SPAWN_PROTECTION = data.spawnProtection;
if (data.viewDistance) envVariables.VIEW_DISTANCE = data.viewDistance;
if (data.simulationDistance) envVariables.SIMULATION_DISTANCE = data.simulationDistance;
if (data.levelSeed) envVariables.SEED = data.levelSeed;
if (data.levelType) envVariables.LEVEL_TYPE = data.levelType;
if (data.generatorSettings) envVariables.GENERATOR_SETTINGS = data.generatorSettings;
if (data.hardcore) envVariables.HARDCORE = String(data.hardcore);
if (data.spawnAnimals !== undefined) envVariables.SPAWN_ANIMALS = String(data.spawnAnimals);
if (data.spawnMonsters !== undefined) envVariables.SPAWN_MONSTERS = String(data.spawnMonsters);
if (data.spawnNpcs !== undefined) envVariables.SPAWN_NPCS = String(data.spawnNpcs);
if (data.enableWhitelist) envVariables.ENABLE_WHITELIST = String(data.enableWhitelist);
if (data.whitelist) envVariables.WHITELIST = data.whitelist;
if (data.whitelistFile) envVariables.WHITELIST_FILE = data.whitelistFile;
if (data.ops) envVariables.OPS = data.ops;
if (data.opsFile) envVariables.OPS_FILE = data.opsFile;
if (data.jvmXxOpts) envVariables.JVM_XX_OPTS = data.jvmXxOpts;
if (data.jvmDdOpts) envVariables.JVM_DD_OPTS = data.jvmDdOpts;
if (data.enableJmx) envVariables.ENABLE_JMX = String(data.enableJmx);
if (data.resourcePack) envVariables.RESOURCE_PACK = data.resourcePack;
if (data.resourcePackSha1) envVariables.RESOURCE_PACK_SHA1 = data.resourcePackSha1;
if (data.resourcePackEnforce)
envVariables.RESOURCE_PACK_ENFORCE = String(data.resourcePackEnforce);
if (data.enableRcon !== undefined) envVariables.ENABLE_RCON = String(data.enableRcon);
if (data.rconPassword) envVariables.RCON_PASSWORD = data.rconPassword;
if (data.rconPort) envVariables.RCON_PORT = data.rconPort;
if (data.rconCmdsStartup) envVariables.RCON_CMDS_STARTUP = data.rconCmdsStartup;
if (data.rconCmdsOnConnect) envVariables.RCON_CMDS_ON_CONNECT = data.rconCmdsOnConnect;
if (data.rconCmdsFirstConnect) envVariables.RCON_CMDS_FIRST_CONNECT = data.rconCmdsFirstConnect;
if (data.rconCmdsOnDisconnect) envVariables.RCON_CMDS_ON_DISCONNECT = data.rconCmdsOnDisconnect;
if (data.rconCmdsLastDisconnect)
envVariables.RCON_CMDS_LAST_DISCONNECT = data.rconCmdsLastDisconnect;
if (data.enableQuery !== undefined) envVariables.ENABLE_QUERY = String(data.enableQuery);
if (data.queryPort) envVariables.QUERY_PORT = data.queryPort;
if (data.enableAutopause) envVariables.ENABLE_AUTOPAUSE = String(data.enableAutopause);
if (data.autopauseTimeoutEst) envVariables.AUTOPAUSE_TIMEOUT_EST = data.autopauseTimeoutEst;
if (data.autopauseTimeoutInit) envVariables.AUTOPAUSE_TIMEOUT_INIT = data.autopauseTimeoutInit;
if (data.autopauseTimeoutKn) envVariables.AUTOPAUSE_TIMEOUT_KN = data.autopauseTimeoutKn;
if (data.autopausePeriod) envVariables.AUTOPAUSE_PERIOD = data.autopausePeriod;
if (data.autopauseKnockInterface)
envVariables.AUTOPAUSE_KNOCK_INTERFACE = data.autopauseKnockInterface;
if (data.enableAutostop) envVariables.ENABLE_AUTOSTOP = String(data.enableAutostop);
if (data.autostopTimeoutEst) envVariables.AUTOSTOP_TIMEOUT_EST = data.autostopTimeoutEst;
if (data.autostopTimeoutInit) envVariables.AUTOSTOP_TIMEOUT_INIT = data.autostopTimeoutInit;
if (data.autostopPeriod) envVariables.AUTOSTOP_PERIOD = data.autostopPeriod;
if (data.plugins) envVariables.PLUGINS = data.plugins;
if (data.removeOldPlugins) envVariables.REMOVE_OLD_PLUGINS = String(data.removeOldPlugins);
if (data.spigetResources) envVariables.SPIGET_RESOURCES = data.spigetResources;
if (data.paperBuild) envVariables.PAPER_BUILD = data.paperBuild;
if (data.type === "CUSTOM" && data.customJarUrl) {
envVariables.CUSTOM_SERVER = data.customJarUrl;
envVariables.VERSION = "";
}
if (data.timezone) envVariables.TZ = data.timezone;
if (data.uid) envVariables.UID = data.uid;
if (data.gid) envVariables.GID = data.gid;
if (data.stopDuration) envVariables.STOP_DURATION = data.stopDuration;
if (data.serverIcon) envVariables.ICON = data.serverIcon;
envVariables.EULA = String(data.eula);
envVariables.TYPE = data.type;
if (data.type !== "CUSTOM" && data.version) {
envVariables.VERSION = data.version;
}
if (data.type === "CUSTOM" && !data.customJarUrl) {
throw new Error("Custom jar URL is required for custom servers");
}
for (const envVar of filteredEnvVars) {
envVariables[envVar.key] = envVar.value;
}
const payload: UpdateServerRequest = {
description: data.description.trim() || null,
listen_port: Number(data.listenPort),
service_type: data.serviceType,
node_port: data.serviceType === "NODE_PORT" && data.nodePort ? Number(data.nodePort) : null,
env_variables: Object.entries(envVariables).map(([key, value]) => ({ key, value })),
memory: data.memoryLimit ? Number(data.memoryLimit) : undefined,
memory_request: data.memoryRequest ? Number(data.memoryRequest) : undefined,
cpu_request: data.cpuRequest || undefined,
cpu_limit: data.cpuLimit || undefined,
jar_type: data.type === "CUSTOM" ? "VANILLA" : data.type,
minecraft_version: data.type === "CUSTOM" ? undefined : data.version || "LATEST",
jvm_opts: data.jvmOpts || undefined,
use_aikar_flags: data.useAikarFlags || undefined,
use_meowice_flags: data.useMeowiceFlags || undefined,
difficulty: toDifficultyUppercase(data.difficulty),
game_mode: toModeUppercase(data.mode),
max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined,
pvp: data.pvp,
online_mode: data.onlineMode,
motd: data.motd,
level_seed: data.levelSeed,
level_type: data.levelType,
};
const payload: UpdateServerRequest = toCommonServerRequestFields(data);
const response = await api.api.servers({ id: serverId }).patch(payload);
@@ -234,227 +85,55 @@ export default function EditServerPage() {
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading server data...</p>
</div>
</div>
);
return <StatePanel loading title="Loading server data..." className="min-h-[50vh]" />;
}
if (error || !serverData) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="bg-destructive/10 text-destructive px-6 py-4 rounded-lg">
{error || "Server not found"}
</div>
<StatePanel
title={error || "Server not found"}
tone="error"
className="min-h-[50vh]"
action={
<Button
className="mt-4"
variant="outline"
onClick={() => router.push("/dashboard/servers")}
>
<ArrowLeft className="h-4 w-4 mr-2" />
<ArrowLeft className="size-4" />
Back to Servers
</Button>
</div>
</div>
}
/>
);
}
const envVars = parseEnvVariables(serverData.env_variables);
const dockerManagedKeys = new Set([
"EULA",
"TYPE",
"VERSION",
"CUSTOM_SERVER",
"MOTD",
"DIFFICULTY",
"MODE",
"MAX_PLAYERS",
"PVP",
"ONLINE_MODE",
"ALLOW_FLIGHT",
"ENABLE_COMMAND_BLOCK",
"SPAWN_PROTECTION",
"VIEW_DISTANCE",
"SIMULATION_DISTANCE",
"LEVEL",
"SEED",
"LEVEL_TYPE",
"GENERATOR_SETTINGS",
"HARDCORE",
"SPAWN_ANIMALS",
"SPAWN_MONSTERS",
"SPAWN_NPCS",
"ENABLE_WHITELIST",
"WHITELIST",
"WHITELIST_FILE",
"OPS",
"OPS_FILE",
"USE_AIKAR_FLAGS",
"USE_MEOWICE_FLAGS",
"JVM_OPTS",
"JVM_XX_OPTS",
"JVM_DD_OPTS",
"ENABLE_JMX",
"RESOURCE_PACK",
"RESOURCE_PACK_SHA1",
"RESOURCE_PACK_ENFORCE",
"ENABLE_RCON",
"RCON_PASSWORD",
"RCON_PORT",
"RCON_CMDS_STARTUP",
"RCON_CMDS_ON_CONNECT",
"RCON_CMDS_FIRST_CONNECT",
"RCON_CMDS_ON_DISCONNECT",
"RCON_CMDS_LAST_DISCONNECT",
"ENABLE_QUERY",
"QUERY_PORT",
"ENABLE_AUTOPAUSE",
"AUTOPAUSE_TIMEOUT_EST",
"AUTOPAUSE_TIMEOUT_INIT",
"AUTOPAUSE_TIMEOUT_KN",
"AUTOPAUSE_PERIOD",
"AUTOPAUSE_KNOCK_INTERFACE",
"ENABLE_AUTOSTOP",
"AUTOSTOP_TIMEOUT_EST",
"AUTOSTOP_TIMEOUT_INIT",
"AUTOSTOP_PERIOD",
"PLUGINS",
"REMOVE_OLD_PLUGINS",
"SPIGET_RESOURCES",
"PAPER_BUILD",
"TZ",
"UID",
"GID",
"STOP_DURATION",
"ICON",
]);
const customEnvVars = Object.entries(envVars)
.filter(([key]) => !dockerManagedKeys.has(key))
.map(([key, value]) => ({ id: crypto.randomUUID(), key, value }));
const initialData: Partial<ServerFormData> = {
id: serverData.id,
description: serverData.description || "",
memoryLimit: String(serverData.memory || 2048),
memoryRequest: String(serverData.memory_request ?? 1024),
cpuRequest: serverData.cpu_request || "500m",
cpuLimit: serverData.cpu_limit || "2",
type: (envVars.TYPE || serverData.jar_type || "PAPER") as ServerType,
version: envVars.VERSION || serverData.minecraft_version || "",
customJarUrl: envVars.CUSTOM_SERVER || undefined,
eula: envVars.EULA === "true",
listenPort: String(serverData.listen_port || 25565),
serviceType: toServiceType(serverData.service_type),
nodePort: serverData.node_port ? String(serverData.node_port) : undefined,
motd: envVars.MOTD || serverData.motd || undefined,
difficulty: toDifficulty(envVars.DIFFICULTY || serverData.difficulty),
mode: toMode(envVars.MODE || serverData.game_mode),
maxPlayers: envVars.MAX_PLAYERS || String(serverData.max_players || 20),
pvp: envVars.PVP ? envVars.PVP === "true" : (serverData.pvp ?? true),
onlineMode: envVars.ONLINE_MODE
? envVars.ONLINE_MODE === "true"
: (serverData.online_mode ?? true),
allowFlight: envVars.ALLOW_FLIGHT === "true",
enableCommandBlock: envVars.ENABLE_COMMAND_BLOCK === "true",
spawnProtection: envVars.SPAWN_PROTECTION || "16",
viewDistance: envVars.VIEW_DISTANCE || "10",
simulationDistance: envVars.SIMULATION_DISTANCE || "10",
levelName: envVars.LEVEL || "world",
levelSeed: envVars.SEED || serverData.level_seed || undefined,
levelType: envVars.LEVEL_TYPE || serverData.level_type || undefined,
generatorSettings: envVars.GENERATOR_SETTINGS || undefined,
hardcore: envVars.HARDCORE === "true",
spawnAnimals: envVars.SPAWN_ANIMALS !== "false",
spawnMonsters: envVars.SPAWN_MONSTERS !== "false",
spawnNpcs: envVars.SPAWN_NPCS !== "false",
enableWhitelist: envVars.ENABLE_WHITELIST === "true",
whitelist: envVars.WHITELIST || undefined,
whitelistFile: envVars.WHITELIST_FILE || undefined,
ops: envVars.OPS || undefined,
opsFile: envVars.OPS_FILE || undefined,
useAikarFlags: envVars.USE_AIKAR_FLAGS === "true" || serverData.use_aikar_flags || false,
useMeowiceFlags: envVars.USE_MEOWICE_FLAGS === "true" || serverData.use_meowice_flags || false,
jvmOpts: envVars.JVM_OPTS || serverData.jvm_opts || undefined,
jvmXxOpts: envVars.JVM_XX_OPTS || undefined,
jvmDdOpts: envVars.JVM_DD_OPTS || undefined,
enableJmx: envVars.ENABLE_JMX === "true",
resourcePack: envVars.RESOURCE_PACK || undefined,
resourcePackSha1: envVars.RESOURCE_PACK_SHA1 || undefined,
resourcePackEnforce: envVars.RESOURCE_PACK_ENFORCE === "true",
enableRcon: envVars.ENABLE_RCON !== "false",
rconPassword: envVars.RCON_PASSWORD || undefined,
rconPort: envVars.RCON_PORT || "25575",
rconCmdsStartup: envVars.RCON_CMDS_STARTUP || undefined,
rconCmdsOnConnect: envVars.RCON_CMDS_ON_CONNECT || undefined,
rconCmdsFirstConnect: envVars.RCON_CMDS_FIRST_CONNECT || undefined,
rconCmdsOnDisconnect: envVars.RCON_CMDS_ON_DISCONNECT || undefined,
rconCmdsLastDisconnect: envVars.RCON_CMDS_LAST_DISCONNECT || undefined,
enableQuery: envVars.ENABLE_QUERY === "true",
queryPort: envVars.QUERY_PORT || "25565",
enableAutopause: envVars.ENABLE_AUTOPAUSE === "true",
autopauseTimeoutEst: envVars.AUTOPAUSE_TIMEOUT_EST || "3600",
autopauseTimeoutInit: envVars.AUTOPAUSE_TIMEOUT_INIT || "600",
autopauseTimeoutKn: envVars.AUTOPAUSE_TIMEOUT_KN || "120",
autopausePeriod: envVars.AUTOPAUSE_PERIOD || "10",
autopauseKnockInterface: envVars.AUTOPAUSE_KNOCK_INTERFACE || "eth0",
enableAutostop: envVars.ENABLE_AUTOSTOP === "true",
autostopTimeoutEst: envVars.AUTOSTOP_TIMEOUT_EST || "3600",
autostopTimeoutInit: envVars.AUTOSTOP_TIMEOUT_INIT || "1800",
autostopPeriod: envVars.AUTOSTOP_PERIOD || "10",
plugins: envVars.PLUGINS || undefined,
removeOldPlugins: envVars.REMOVE_OLD_PLUGINS === "true",
spigetResources: envVars.SPIGET_RESOURCES || undefined,
paperBuild: envVars.PAPER_BUILD || undefined,
serverIcon: envVars.ICON || undefined,
envVars: customEnvVars,
};
const initialData = toInitialServerFormData(serverData);
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<PageShell>
<PageHeader
eyebrow="Compute / Configure"
title={<>Edit {serverData.id}</>}
description="Update the workload specification and deployment behavior."
leading={
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard/servers")}>
<ArrowLeft className="h-5 w-5" />
<ArrowLeft className="size-5" />
</Button>
<div>
<h1 className="text-3xl font-bold">Edit Server: {serverData.id}</h1>
<p className="text-muted-foreground mt-1">Update your Minecraft server configuration</p>
</div>
</div>
}
className="flex-row items-center justify-start"
/>
<Card>
<CardHeader>
<CardTitle>Server Configuration</CardTitle>
<CardDescription>Modify settings for your Minecraft server</CardDescription>
</CardHeader>
<CardContent>
<SectionCard
title="Server Configuration"
description="Modify settings for your Minecraft server"
>
<ServerForm
initialData={initialData}
onSubmit={handleSubmit}
onCancel={() => router.push("/dashboard/servers")}
submitLabel="Save Changes"
/>
</CardContent>
</Card>
</div>
</SectionCard>
</PageShell>
);
}
+67 -416
View File
@@ -1,451 +1,102 @@
"use client";
import type { ConnectionInfo, NormalServer, PodInfo, ReverseProxyServer } from "@minikura/api";
import {
AlertCircle,
Check,
CheckCircle2,
Copy,
FileText,
Globe,
Pencil,
Plus,
Server,
Trash2,
} from "lucide-react";
import { Globe, Plus, Server, ServerCog } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
import { ResourceSection } from "@/components/section-card";
import { ServerTable } from "@/components/servers/server-table";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
function ServerStatusCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) {
const [pods, setPods] = useState<PodInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPods = async () => {
try {
const endpoint =
type === "normal"
? api.api.k8s.servers({ serverId }).pods.get
: api.api.k8s["reverse-proxy"]({ serverId }).pods.get;
const res = await endpoint();
if (res.data) {
setPods(res.data as PodInfo[]);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchPods();
}, [serverId, type]);
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="h-3 w-3 animate-pulse bg-muted rounded-full" />
Loading...
</div>
);
}
if (pods.length === 0) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<AlertCircle className="h-3 w-3" />
No pods
</div>
);
}
const allRunning = pods.every((pod) => pod.status === "Running");
const readyCount = pods.filter((pod) => pod.ready === "1/1").length;
return (
<div className="flex items-center gap-2">
{allRunning ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertCircle className="h-3 w-3 text-yellow-500" />
)}
<span className="text-xs">
{readyCount}/{pods.length} Ready
</span>
</div>
);
}
function ConnectionInfoCell({ serverId, type }: { serverId: string; type: "normal" | "proxy" }) {
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
const fetchConnectionInfo = async () => {
try {
const reverseProxyApi = getReverseProxyApi();
const endpoint =
type === "normal"
? api.api.servers({ id: serverId })["connection-info"]
: reverseProxyApi({ id: serverId })["connection-info"];
const res = await endpoint.get();
if (res.data) {
setConnectionInfo(res.data as ConnectionInfo);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchConnectionInfo();
}, [serverId, type]);
const handleCopy = async () => {
if (connectionInfo?.connectionString) {
await navigator.clipboard.writeText(connectionInfo.connectionString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (loading) {
return <span className="text-muted-foreground text-xs">Loading...</span>;
}
if (!connectionInfo) {
return <span className="text-muted-foreground text-xs">N/A</span>;
}
return (
<TooltipProvider>
<div className="flex flex-col gap-1">
<Badge variant="secondary" className="w-fit text-xs">
{connectionInfo.type}
</Badge>
{connectionInfo.connectionString && (
<div className="flex items-center gap-1">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{connectionInfo.connectionString}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={handleCopy}>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
</TooltipContent>
</Tooltip>
</div>
)}
{connectionInfo.note && (
<p className="text-xs text-muted-foreground">{connectionInfo.note}</p>
)}
</div>
</TooltipProvider>
);
}
import { useServerList } from "@/hooks/use-server-list";
export default function ServersPage() {
const router = useRouter();
const [normalServers, setNormalServers] = useState<NormalServer[]>([]);
const [reverseProxies, setReverseProxies] = useState<ReverseProxyServer[]>([]);
const [loading, setLoading] = useState(true);
const { normalServers, reverseProxies, loading, error, deleteServer } = useServerList();
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
type: "normal" | "proxy";
} | null>(null);
const fetchServers = useCallback(async () => {
try {
const reverseProxyApi = getReverseProxyApi();
const [normalRes, proxyRes] = await Promise.all([
api.api.servers.get(),
reverseProxyApi.get(),
]);
if (normalRes.data) {
setNormalServers(normalRes.data as unknown as NormalServer[]);
}
if (proxyRes.data) {
setReverseProxies(proxyRes.data as unknown as ReverseProxyServer[]);
}
} catch (_error) {
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleDelete = async () => {
if (!deleteTarget) return;
try {
const reverseProxyApi = getReverseProxyApi();
if (deleteTarget.type === "normal") {
await api.api.servers({ id: deleteTarget.id }).delete();
} else {
await reverseProxyApi({ id: deleteTarget.id }).delete();
}
await fetchServers();
await deleteServer(deleteTarget.id, deleteTarget.type);
setDeleteTarget(null);
} catch (_error) {}
};
if (loading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Server Management</h1>
<p className="text-muted-foreground mt-1">Manage your Minecraft servers</p>
</div>
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Server Management</h1>
<p className="text-muted-foreground mt-1">
Manage your Minecraft servers and reverse proxies
</p>
</div>
<Button onClick={() => router.push("/dashboard/servers/create")}>
<Plus className="h-4 w-4 mr-2" />
<PageShell>
<PageHeader
eyebrow="Workloads"
title="Servers"
description="Provision Minecraft runtimes and route traffic through edge proxies."
actions={
<Button size="lg" onClick={() => router.push("/dashboard/servers/create")}>
<Plus className="size-4" />
Create Server
</Button>
</div>
}
/>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Server className="h-5 w-5" />
<CardTitle>Minecraft Servers</CardTitle>
</div>
<CardDescription>Manage your normal Minecraft server instances</CardDescription>
</CardHeader>
<CardContent>
{normalServers.length === 0 ? (
<div className="flex items-center justify-center h-32 border-2 border-dashed rounded-lg">
<p className="text-muted-foreground">No servers created yet</p>
</div>
{loading ? (
<StatePanel loading title="Discovering workloads..." className="h-64" />
) : error ? (
<StatePanel title="Unable to load workloads" description={error} tone="error" />
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Storage</TableHead>
<TableHead>Software</TableHead>
<TableHead>Version</TableHead>
<TableHead>Memory (MB)</TableHead>
<TableHead>Network</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalServers.map((server) => (
<TableRow key={server.id}>
<TableCell className="font-medium">{server.id}</TableCell>
<TableCell>
<ServerStatusCell serverId={server.id} type="normal" />
</TableCell>
<TableCell>
<Badge variant="outline">{server.type}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{server.jar_type || "VANILLA"}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{server.minecraft_version || "LATEST"}
</TableCell>
<TableCell>{server.memory || 1024}</TableCell>
<TableCell>
<ConnectionInfoCell serverId={server.id} type="normal" />
</TableCell>
<TableCell className="text-muted-foreground">
{server.description || "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/servers/${server.id}/logs`)}
title="View Logs"
<>
<ResourceSection
title="Minecraft Workloads"
description="Manage your normal Minecraft server instances"
icon={<ServerCog className="size-5 text-primary" />}
count={normalServers.length}
emptyIcon={<Server className="size-6" />}
emptyTitle="No workloads"
emptyDescription="Create a server to begin."
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/servers/edit/${server.id}`)}
title="Edit Server"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget({ id: server.id, type: "normal" })}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<ServerTable
type="normal"
servers={normalServers}
onEdit={(id) => router.push(`/dashboard/servers/edit/${id}`)}
onDelete={(id) => setDeleteTarget({ id, type: "normal" })}
/>
</ResourceSection>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5" />
<CardTitle>Reverse Proxy Servers</CardTitle>
</div>
<CardDescription>Manage your Velocity and BungeeCord proxy servers</CardDescription>
</CardHeader>
<CardContent>
{reverseProxies.length === 0 ? (
<div className="flex items-center justify-center h-32 border-2 border-dashed rounded-lg">
<p className="text-muted-foreground">No reverse proxies created yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Type</TableHead>
<TableHead>External</TableHead>
<TableHead>Listen Port</TableHead>
<TableHead>Memory (MB)</TableHead>
<TableHead>Network</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reverseProxies.map((proxy) => (
<TableRow key={proxy.id}>
<TableCell className="font-medium">{proxy.id}</TableCell>
<TableCell>
<ServerStatusCell serverId={proxy.id} type="proxy" />
</TableCell>
<TableCell>
<Badge variant="outline">{proxy.type}</Badge>
</TableCell>
<TableCell>
{proxy.external_address}:{proxy.external_port}
</TableCell>
<TableCell>{proxy.listen_port}</TableCell>
<TableCell>{proxy.memory}</TableCell>
<TableCell>
<ConnectionInfoCell serverId={proxy.id} type="proxy" />
</TableCell>
<TableCell className="text-muted-foreground">
{proxy.description || "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/servers/${proxy.id}/logs`)}
title="View Logs"
<ResourceSection
title="Edge Proxies"
description="Manage your Velocity and BungeeCord proxy servers"
icon={<Globe className="size-5 text-primary" />}
count={reverseProxies.length}
emptyIcon={<Globe className="size-6" />}
emptyTitle="No edge proxies"
emptyDescription="Proxy workloads will appear here."
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/servers/edit/${proxy.id}`)}
title="Edit Server"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget({ id: proxy.id, type: "proxy" })}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ServerTable
type="proxy"
servers={reverseProxies}
onEdit={(id) => router.push(`/dashboard/servers/edit/${id}`)}
onDelete={(id) => setDeleteTarget({ id, type: "proxy" })}
/>
</ResourceSection>
</>
)}
</CardContent>
</Card>
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
<ConfirmDialog
open={!!deleteTarget}
title="Delete Server"
description={
<>
Are you sure you want to delete this{" "}
{deleteTarget?.type === "normal" ? "server" : "reverse proxy"}? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
}
confirmLabel="Delete"
onConfirm={handleDelete}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
</PageShell>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { Info } from "lucide-react";
import type * as React from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cn";
export function HelpTooltip({ content }: { content: React.ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-muted-foreground hover:text-foreground">
<Info className="size-4" />
<span className="sr-only">More information</span>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-64">{content}</TooltipContent>
</Tooltip>
);
}
export function FormNotice({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("rounded-sm border border-dashed p-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
import { Plus, Trash2 } from "lucide-react";
import { HelpTooltip } from "@/components/form-controls";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
interface AdvancedPanelProps extends ServerFormPanelProps {
addEnvVar: () => void;
removeEnvVar: (index: number) => void;
updateEnvVar: (index: number, field: "key" | "value", value: string) => void;
}
export function AdvancedPanel({
formData,
updateField,
addEnvVar,
removeEnvVar,
updateEnvVar,
}: AdvancedPanelProps) {
return (
<TabsContent value="advanced" className="space-y-4 mt-4">
<div className="grid grid-cols-3 gap-4">
<Field id="timezone" label="Timezone" tooltip="Timezone for server logs and scheduling (e.g., America/New_York)">
<Input
id="timezone"
value={formData.timezone}
onChange={(event) => updateField("timezone", event.target.value)}
placeholder="UTC"
/>
</Field>
<Field id="uid" label="User ID (UID)" tooltip="Linux user ID for the Minecraft process">
<Input
id="uid"
value={formData.uid}
onChange={(event) => updateField("uid", event.target.value)}
placeholder="1000"
/>
</Field>
<Field id="gid" label="Group ID (GID)" tooltip="Linux group ID for the Minecraft process">
<Input
id="gid"
value={formData.gid}
onChange={(event) => updateField("gid", event.target.value)}
placeholder="1000"
/>
</Field>
</div>
<Field
id="stopDuration"
label="Graceful Shutdown Timeout (seconds)"
tooltip="How long to wait for graceful shutdown before forcing stop"
>
<Input
id="stopDuration"
type="number"
value={formData.stopDuration}
onChange={(event) => updateField("stopDuration", event.target.value)}
min="1"
/>
</Field>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>
Custom Environment Variables
<HelpTooltip content="Additional environment variables to pass to the container" />
</Label>
<Button type="button" variant="outline" size="sm" onClick={addEnvVar}>
<Plus className="h-4 w-4 mr-2" />
Add Variable
</Button>
</div>
{formData.envVars.length > 0 && (
<div className="space-y-3">
{formData.envVars.map((envVar, index) => (
<div key={envVar.id} className="flex gap-2">
<Input
placeholder="KEY"
value={envVar.key}
onChange={(event) => updateEnvVar(index, "key", event.target.value)}
className="flex-1 font-mono"
/>
<Input
placeholder="value"
value={envVar.value}
onChange={(event) => updateEnvVar(index, "value", event.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeEnvVar(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</TabsContent>
);
}
@@ -0,0 +1,194 @@
import { FormNotice } from "@/components/form-controls";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function AutomationPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="automation" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Automation hooks depend on container scripts; custom jars may ignore them.
</FormNotice>
)}
<Accordion type="multiple" className="w-full">
<AccordionItem value="rcon">
<AccordionTrigger>RCON Configuration</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableRcon"
checked={formData.enableRcon}
onCheckedChange={(value) => updateField("enableRcon", value)}
tooltip="Remote console for server management. Enabled by default for graceful shutdown."
>
Enable RCON
</CheckboxField>
{formData.enableRcon && (
<>
<div className="grid grid-cols-2 gap-4">
<Field id="rconPassword" label="RCON Password">
<Input
id="rconPassword"
type="password"
value={formData.rconPassword || ""}
onChange={(event) => updateField("rconPassword", event.target.value)}
placeholder="Auto-generated if empty"
/>
</Field>
<Field id="rconPort" label="RCON Port">
<Input
id="rconPort"
type="number"
value={formData.rconPort}
onChange={(event) => updateField("rconPort", event.target.value)}
min="1"
max="65535"
/>
</Field>
</div>
<Field
id="rconCmdsStartup"
label="RCON Commands on Startup"
tooltip="Semicolon-separated commands to run when server starts"
>
<Textarea
id="rconCmdsStartup"
value={formData.rconCmdsStartup || ""}
onChange={(event) => updateField("rconCmdsStartup", event.target.value)}
placeholder="say Server starting...;gamerule doDaylightCycle false"
rows={2}
/>
</Field>
<Field id="rconCmdsOnConnect" label="Commands on Player Connect">
<Textarea
id="rconCmdsOnConnect"
value={formData.rconCmdsOnConnect || ""}
onChange={(event) => updateField("rconCmdsOnConnect", event.target.value)}
placeholder="tell {{player}} Welcome to the server!"
rows={2}
/>
</Field>
</>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="query">
<AccordionTrigger>Query Protocol</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableQuery"
checked={formData.enableQuery}
onCheckedChange={(value) => updateField("enableQuery", value)}
tooltip="Allows external tools to query server status and player list"
>
Enable Query Protocol
</CheckboxField>
{formData.enableQuery && (
<Field id="queryPort" label="Query Port">
<Input
id="queryPort"
type="number"
value={formData.queryPort}
onChange={(event) => updateField("queryPort", event.target.value)}
min="1"
max="65535"
/>
</Field>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="autopause">
<AccordionTrigger>Auto-Pause</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableAutopause"
checked={formData.enableAutopause}
onCheckedChange={(value) => updateField("enableAutopause", value)}
tooltip="Automatically pauses the server when no players are online to save resources"
>
Enable Auto-Pause
</CheckboxField>
{formData.enableAutopause && (
<>
<div className="grid grid-cols-2 gap-4">
<Field id="autopauseTimeoutEst" label="Timeout After Disconnect (seconds)">
<Input
id="autopauseTimeoutEst"
type="number"
value={formData.autopauseTimeoutEst}
onChange={(event) => updateField("autopauseTimeoutEst", event.target.value)}
min="0"
/>
</Field>
<Field id="autopauseTimeoutInit" label="Timeout After Startup (seconds)">
<Input
id="autopauseTimeoutInit"
type="number"
value={formData.autopauseTimeoutInit}
onChange={(event) => updateField("autopauseTimeoutInit", event.target.value)}
min="0"
/>
</Field>
</div>
<Field id="autopauseKnockInterface" label="Network Interface">
<Input
id="autopauseKnockInterface"
value={formData.autopauseKnockInterface}
onChange={(event) => updateField("autopauseKnockInterface", event.target.value)}
placeholder="eth0"
/>
</Field>
</>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="autostop">
<AccordionTrigger>Auto-Stop</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableAutostop"
checked={formData.enableAutostop}
onCheckedChange={(value) => updateField("enableAutostop", value)}
tooltip="Automatically stops the server when no players are online"
>
Enable Auto-Stop
</CheckboxField>
{formData.enableAutostop && (
<div className="grid grid-cols-2 gap-4">
<Field id="autostopTimeoutEst" label="Timeout After Player Departure (seconds)">
<Input
id="autostopTimeoutEst"
type="number"
value={formData.autostopTimeoutEst}
onChange={(event) => updateField("autostopTimeoutEst", event.target.value)}
min="0"
/>
</Field>
<Field id="autostopTimeoutInit" label="Timeout After Launch (seconds)">
<Input
id="autostopTimeoutInit"
type="number"
value={formData.autostopTimeoutInit}
onChange={(event) => updateField("autostopTimeoutInit", event.target.value)}
min="0"
/>
</Field>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
);
}
@@ -0,0 +1,115 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function BasicPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="basic" className="space-y-4 mt-4">
<Field id="id" label="Server ID *">
<Input
id="id"
value={formData.id}
onChange={(event) => updateField("id", event.target.value)}
placeholder="my-server"
required
/>
</Field>
<Field id="description" label="Description">
<Textarea
id="description"
value={formData.description}
onChange={(event) => updateField("description", event.target.value)}
placeholder="A brief description of this server"
rows={2}
/>
</Field>
<div className="grid grid-cols-4 gap-4">
<Field
id="memoryRequest"
label="Memory Request (MB) *"
tooltip="Kubernetes guaranteed memory. Java heap sizes from the limit."
>
<Input
id="memoryRequest"
type="number"
value={formData.memoryRequest}
onChange={(event) => updateField("memoryRequest", event.target.value)}
min="256"
required
/>
</Field>
<Field
id="memoryLimit"
label="Memory Limit (MB) *"
tooltip="Kubernetes max memory. Java heap scales from this."
>
<Input
id="memoryLimit"
type="number"
value={formData.memoryLimit}
onChange={(event) => updateField("memoryLimit", event.target.value)}
min="256"
required
/>
</Field>
<Field
id="cpuRequest"
label="CPU Request *"
tooltip="Guaranteed CPU allocation (e.g., 500m = 0.5 cores)"
>
<Input
id="cpuRequest"
value={formData.cpuRequest}
onChange={(event) => updateField("cpuRequest", event.target.value)}
placeholder="500m"
/>
</Field>
<Field id="cpuLimit" label="CPU Limit *" tooltip="Maximum CPU the server can use">
<Input
id="cpuLimit"
value={formData.cpuLimit}
onChange={(event) => updateField("cpuLimit", event.target.value)}
placeholder="2"
/>
</Field>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced-memory">
<AccordionTrigger>Java Heap Overrides</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm text-muted-foreground">
Heap sizes are derived from the memory limit. Override only if needed.
</AccordionContent>
</AccordionItem>
</Accordion>
<CheckboxField
id="eula"
checked={formData.eula}
onCheckedChange={(checked) => updateField("eula", checked)}
labelClassName="cursor-pointer"
>
I accept the{" "}
<a
href="https://www.minecraft.net/eula"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Minecraft EULA
</a>{" "}
*
</CheckboxField>
</TabsContent>
);
}
@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import { HelpTooltip } from "@/components/form-controls";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
interface FieldProps {
id: string;
label: ReactNode;
tooltip?: string;
children: ReactNode;
}
export function Field({ id, label, tooltip, children }: FieldProps) {
return (
<div className="space-y-2">
<Label htmlFor={id}>
{label}
{tooltip && <HelpTooltip content={tooltip} />}
</Label>
{children}
</div>
);
}
interface CheckboxFieldProps {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
children: ReactNode;
tooltip?: string;
labelClassName?: string;
}
export function CheckboxField({
id,
checked,
onCheckedChange,
children,
tooltip,
labelClassName,
}: CheckboxFieldProps) {
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(value) => onCheckedChange(value as boolean)}
/>
<Label htmlFor={id} className={labelClassName}>
{children}
{tooltip && <HelpTooltip content={tooltip} />}
</Label>
</div>
);
}
@@ -0,0 +1,50 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="mods" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
)}
<Field id="plugins" label="Plugins" tooltip="Comma-separated list of plugin URLs or filenames">
<Textarea
id="plugins"
value={formData.plugins || ""}
onChange={(event) => updateField("plugins", event.target.value)}
placeholder="https://example.com/plugin1.jar,plugin2.jar"
rows={3}
/>
<p className="text-sm text-muted-foreground">
For Paper/Spigot/Bukkit servers. URLs or local filenames.
</p>
</Field>
<Field
id="spigetResources"
label="Spiget Resource IDs"
tooltip="Comma-separated Spiget resource IDs to auto-download from SpigotMC"
>
<Input
id="spigetResources"
value={formData.spigetResources || ""}
onChange={(event) => updateField("spigetResources", event.target.value)}
placeholder="1234,5678,9012"
/>
</Field>
<div className="space-y-3">
<CheckboxField
id="removeOldPlugins"
checked={formData.removeOldPlugins}
onCheckedChange={(value) => updateField("removeOldPlugins", value)}
tooltip="Automatically remove old versions of plugins when updating"
>
Remove Old Plugins
</CheckboxField>
</div>
</TabsContent>
);
}
@@ -0,0 +1,66 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsContent } from "@/components/ui/tabs";
import { Field } from "./fields";
import type { ServerFormPanelProps, ServiceType } from "./types";
export function NetworkPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="network" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Network settings apply at the container level and still work with custom jars.
</FormNotice>
)}
<div className="grid grid-cols-2 gap-4">
<Field id="listenPort" label="Listen Port">
<Input
id="listenPort"
type="number"
value={formData.listenPort}
onChange={(event) => updateField("listenPort", event.target.value)}
min="1"
max="65535"
/>
</Field>
<Field id="serviceType" label="Service Type" tooltip="How the server is exposed in Kubernetes">
<Select
value={formData.serviceType}
onValueChange={(value) => updateField("serviceType", value as ServiceType)}
>
<SelectTrigger id="serviceType"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="CLUSTER_IP">ClusterIP (Internal Only)</SelectItem>
<SelectItem value="NODE_PORT">NodePort (External Access)</SelectItem>
<SelectItem value="LOAD_BALANCER">LoadBalancer (Cloud/MetalLB)</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
{formData.serviceType === "NODE_PORT" && (
<Field
id="nodePort"
label="Node Port"
tooltip="Specific port on the node (30000-32767). Leave empty for auto-assignment."
>
<Input
id="nodePort"
type="number"
value={formData.nodePort || ""}
onChange={(event) => updateField("nodePort", event.target.value)}
placeholder="30000-32767"
min="30000"
max="32767"
/>
</Field>
)}
</TabsContent>
);
}
@@ -0,0 +1,74 @@
import { FormNotice } from "@/components/form-controls";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function PerformancePanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="performance" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Custom jars may not honor built-in tuning flags. Use custom env vars if required.
</FormNotice>
)}
<div className="space-y-3">
<CheckboxField
id="useAikarFlags"
checked={formData.useAikarFlags}
onCheckedChange={(value) => updateField("useAikarFlags", value)}
tooltip="Optimized G1GC garbage collection flags. Recommended for servers with 1GB+ RAM."
>
Use Aikar's Flags
</CheckboxField>
<CheckboxField
id="useMeowiceFlags"
checked={formData.useMeowiceFlags}
onCheckedChange={(value) => updateField("useMeowiceFlags", value)}
tooltip="Modern optimization flags for Java 17+. Based on Aikar's flags with improvements."
>
Use Meowice Flags
</CheckboxField>
<CheckboxField
id="enableJmx"
checked={formData.enableJmx}
onCheckedChange={(value) => updateField("enableJmx", value)}
tooltip="Enables remote JMX monitoring for advanced profiling"
>
Enable JMX
</CheckboxField>
</div>
<Field
id="jvmOpts"
label="Custom JVM Options"
tooltip="Space-separated JVM arguments (e.g., -Xms2G -Xmx4G)"
>
<Textarea
id="jvmOpts"
value={formData.jvmOpts || ""}
onChange={(event) => updateField("jvmOpts", event.target.value)}
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
rows={3}
/>
</Field>
<Field id="jvmXxOpts" label="JVM -XX Options" tooltip="Space-separated -XX JVM flags for advanced tuning">
<Textarea
id="jvmXxOpts"
value={formData.jvmXxOpts || ""}
onChange={(event) => updateField("jvmXxOpts", event.target.value)}
placeholder="+UnlockExperimentalVMOptions +UseZGC"
rows={2}
/>
</Field>
<Field id="jvmDdOpts" label="JVM -D System Properties" tooltip="Comma-separated key=value pairs for system properties">
<Textarea
id="jvmDdOpts"
value={formData.jvmDdOpts || ""}
onChange={(event) => updateField("jvmDdOpts", event.target.value)}
placeholder="property1=value1,property2=value2"
rows={2}
/>
</Field>
</TabsContent>
);
}
@@ -0,0 +1,74 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function PlayersPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="players" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>Some custom jars do not support managed whitelist/ops settings.</FormNotice>
)}
<CheckboxField
id="enableWhitelist"
checked={formData.enableWhitelist}
onCheckedChange={(value) => updateField("enableWhitelist", value)}
tooltip="Only whitelisted players can join the server"
>
Enable Whitelist
</CheckboxField>
{formData.enableWhitelist && (
<>
<Field
id="whitelist"
label="Whitelisted Players"
tooltip="Comma-separated list of usernames or UUIDs"
>
<Textarea
id="whitelist"
value={formData.whitelist || ""}
onChange={(event) => updateField("whitelist", event.target.value)}
placeholder="Player1,Player2,UUID-here"
rows={3}
/>
</Field>
<Field
id="whitelistFile"
label="Whitelist File URL"
tooltip="URL or path to a whitelist.json file. Overrides the whitelist field."
>
<Input
id="whitelistFile"
value={formData.whitelistFile || ""}
onChange={(event) => updateField("whitelistFile", event.target.value)}
placeholder="https://example.com/whitelist.json"
/>
</Field>
</>
)}
<Field
id="ops"
label="Operators (Ops)"
tooltip="Comma-separated list of usernames or UUIDs with full server permissions"
>
<Textarea
id="ops"
value={formData.ops || ""}
onChange={(event) => updateField("ops", event.target.value)}
placeholder="Admin1,Admin2"
rows={3}
/>
</Field>
<Field id="opsFile" label="Ops File URL">
<Input
id="opsFile"
value={formData.opsFile || ""}
onChange={(event) => updateField("opsFile", event.target.value)}
placeholder="https://example.com/ops.json"
/>
</Field>
</TabsContent>
);
}
@@ -0,0 +1,47 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function ResourcesPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="resources" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>Resource pack settings may not apply to custom jars.</FormNotice>
)}
<Field id="resourcePack" label="Resource Pack URL" tooltip="URL or path to a resource pack ZIP file">
<Input
id="resourcePack"
value={formData.resourcePack || ""}
onChange={(event) => updateField("resourcePack", event.target.value)}
placeholder="https://example.com/resourcepack.zip"
/>
</Field>
<Field id="resourcePackSha1" label="Resource Pack SHA1" tooltip="SHA1 checksum of the resource pack for verification">
<Input
id="resourcePackSha1"
value={formData.resourcePackSha1 || ""}
onChange={(event) => updateField("resourcePackSha1", event.target.value)}
placeholder="a1b2c3d4e5f6..."
/>
</Field>
<CheckboxField
id="resourcePackEnforce"
checked={formData.resourcePackEnforce}
onCheckedChange={(value) => updateField("resourcePackEnforce", value)}
tooltip="Require players to accept the resource pack to join"
>
Enforce Resource Pack
</CheckboxField>
<Field id="serverIcon" label="Server Icon URL" tooltip="URL or path to a server icon image (PNG, 64x64 recommended)">
<Input
id="serverIcon"
value={formData.serverIcon || ""}
onChange={(event) => updateField("serverIcon", event.target.value)}
placeholder="https://example.com/icon.png"
/>
</Field>
</TabsContent>
);
}
@@ -0,0 +1,177 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormData, ServerFormPanelProps, ServerType } from "./types";
const toMode = (value: string): ServerFormData["mode"] => {
if (value === "creative" || value === "adventure" || value === "spectator") return value;
return "survival";
};
const toDifficulty = (value: string): ServerFormData["difficulty"] => {
if (value === "peaceful" || value === "normal" || value === "hard") return value;
return "easy";
};
export function ServerPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="server" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Custom jars may ignore built-in server settings. Configure extra values via custom
environment variables if needed.
</FormNotice>
)}
<Field
id="type"
label="Server Type *"
tooltip="Choose the server software (Vanilla, Paper, or Custom Jar)."
>
<Select
value={formData.type}
onValueChange={(value) => updateField("type", value as ServerType)}
>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="VANILLA">Vanilla (Official Mojang)</SelectItem>
<SelectItem value="PAPER">Paper (Recommended - High Performance)</SelectItem>
<SelectItem value="CUSTOM">Custom Jar</SelectItem>
</SelectContent>
</Select>
</Field>
{formData.type !== "CUSTOM" && (
<Field
id="version"
label="Minecraft Version"
tooltip="Use LATEST for newest stable, SNAPSHOT for snapshots, or specific version like 1.20.4"
>
<Input
id="version"
value={formData.version || ""}
onChange={(event) => updateField("version", event.target.value)}
placeholder="LATEST"
/>
</Field>
)}
{formData.type === "PAPER" && (
<Field id="paperBuild" label="Paper Build Number">
<Input
id="paperBuild"
value={formData.paperBuild || ""}
onChange={(event) => updateField("paperBuild", event.target.value)}
placeholder="Leave empty for latest"
/>
</Field>
)}
{formData.type === "CUSTOM" && (
<Field id="customJarUrl" label="Custom Server Jar URL/Path">
<Input
id="customJarUrl"
value={formData.customJarUrl || ""}
onChange={(event) => updateField("customJarUrl", event.target.value)}
placeholder="https://example.com/server.jar"
/>
<p className="text-sm text-muted-foreground">
Sets CUSTOM_SERVER for the itzg/minecraft-server image.
</p>
</Field>
)}
<Field id="motd" label="Message of the Day (MOTD)">
<Textarea
id="motd"
value={formData.motd || ""}
onChange={(event) => updateField("motd", event.target.value)}
placeholder="A Minecraft Server"
rows={2}
/>
<p className="text-sm text-muted-foreground">
Supports formatting codes like §l for bold, §c for red
</p>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field id="mode" label="Game Mode">
<Select value={formData.mode} onValueChange={(value) => updateField("mode", toMode(value))}>
<SelectTrigger id="mode"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="survival">Survival</SelectItem>
<SelectItem value="creative">Creative</SelectItem>
<SelectItem value="adventure">Adventure</SelectItem>
<SelectItem value="spectator">Spectator</SelectItem>
</SelectContent>
</Select>
</Field>
<Field id="difficulty" label="Difficulty">
<Select
value={formData.difficulty}
onValueChange={(value) => updateField("difficulty", toDifficulty(value))}
>
<SelectTrigger id="difficulty"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="peaceful">Peaceful</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field id="maxPlayers" label="Max Players">
<Input
id="maxPlayers"
type="number"
value={formData.maxPlayers}
onChange={(event) => updateField("maxPlayers", event.target.value)}
min="1"
max="1000"
/>
</Field>
<Field id="viewDistance" label="View Distance (chunks)">
<Input
id="viewDistance"
type="number"
value={formData.viewDistance}
onChange={(event) => updateField("viewDistance", event.target.value)}
min="3"
max="32"
/>
</Field>
</div>
<div className="space-y-3">
<CheckboxField id="pvp" checked={formData.pvp} onCheckedChange={(value) => updateField("pvp", value)}>
Enable PvP (Player vs Player)
</CheckboxField>
<CheckboxField id="onlineMode" checked={formData.onlineMode} onCheckedChange={(value) => updateField("onlineMode", value)}>
Online Mode (Requires authenticated Minecraft accounts)
</CheckboxField>
<CheckboxField id="allowFlight" checked={formData.allowFlight} onCheckedChange={(value) => updateField("allowFlight", value)}>
Allow Flight
</CheckboxField>
<CheckboxField id="enableCommandBlock" checked={formData.enableCommandBlock} onCheckedChange={(value) => updateField("enableCommandBlock", value)}>
Enable Command Blocks
</CheckboxField>
<CheckboxField id="hardcore" checked={formData.hardcore} onCheckedChange={(value) => updateField("hardcore", value)}>
Hardcore Mode (Permanent Death)
</CheckboxField>
</div>
</TabsContent>
);
}
+101
View File
@@ -0,0 +1,101 @@
import type { GameMode, ServiceType as PrismaServiceType, ServerDifficulty } from "@minikura/db";
export type ServerType = "VANILLA" | "PAPER" | "SPIGOT" | "PURPUR" | "FABRIC" | "CUSTOM";
export type ServiceType = PrismaServiceType;
export type Difficulty = ServerDifficulty;
export type Mode = GameMode;
export interface EnvVar {
id?: string;
key: string;
value: string;
}
export interface ServerFormData {
id: string;
description: string;
memoryLimit: string;
memoryRequest: string;
cpuRequest: string;
cpuLimit: string;
type: ServerType;
version?: string;
customJarUrl?: string;
eula: boolean;
listenPort: string;
serviceType: ServiceType;
nodePort?: string;
motd?: string;
difficulty: "peaceful" | "easy" | "normal" | "hard";
mode: "survival" | "creative" | "adventure" | "spectator";
maxPlayers: string;
pvp: boolean;
onlineMode: boolean;
allowFlight: boolean;
enableCommandBlock: boolean;
spawnProtection: string;
viewDistance: string;
simulationDistance: string;
levelName: string;
levelSeed?: string;
levelType?: string;
generatorSettings?: string;
hardcore: boolean;
spawnAnimals: boolean;
spawnMonsters: boolean;
spawnNpcs: boolean;
enableWhitelist: boolean;
whitelist?: string;
whitelistFile?: string;
ops?: string;
opsFile?: string;
useAikarFlags: boolean;
useMeowiceFlags: boolean;
jvmOpts?: string;
jvmXxOpts?: string;
jvmDdOpts?: string;
resourcePack?: string;
resourcePackSha1?: string;
resourcePackEnforce: boolean;
enableRcon: boolean;
rconPassword?: string;
rconPort: string;
rconCmdsStartup?: string;
rconCmdsOnConnect?: string;
rconCmdsFirstConnect?: string;
rconCmdsOnDisconnect?: string;
rconCmdsLastDisconnect?: string;
enableQuery: boolean;
queryPort: string;
enableAutopause: boolean;
autopauseTimeoutEst: string;
autopauseTimeoutInit: string;
autopauseTimeoutKn: string;
autopausePeriod: string;
autopauseKnockInterface: string;
enableAutostop: boolean;
autostopTimeoutEst: string;
autostopTimeoutInit: string;
autostopPeriod: string;
plugins?: string;
removeOldPlugins: boolean;
spigetResources?: string;
paperBuild?: string;
timezone: string;
uid: string;
gid: string;
enableJmx: boolean;
stopDuration: string;
serverIcon?: string;
envVars: EnvVar[];
}
export type UpdateServerField = <K extends keyof ServerFormData>(
key: K,
value: ServerFormData[K],
) => void;
export interface ServerFormPanelProps {
formData: ServerFormData;
updateField: UpdateServerField;
}
@@ -0,0 +1,113 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function WorldPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="world" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
World options apply to standard server properties and may be ignored by custom jars.
</FormNotice>
)}
<Field id="levelName" label="World Name">
<Input
id="levelName"
value={formData.levelName}
onChange={(event) => updateField("levelName", event.target.value)}
placeholder="world"
/>
</Field>
<Field
id="levelSeed"
label="World Seed"
tooltip="Leave empty for random generation. Can be numeric or text."
>
<Input
id="levelSeed"
value={formData.levelSeed || ""}
onChange={(event) => updateField("levelSeed", event.target.value)}
placeholder="Leave empty for random"
/>
</Field>
<Field id="levelType" label="Level Type">
<Select
value={formData.levelType || "default"}
onValueChange={(value) => updateField("levelType", value === "default" ? "" : value)}
>
<SelectTrigger id="levelType"><SelectValue placeholder="Default" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="flat">Flat/Superflat</SelectItem>
<SelectItem value="largeBiomes">Large Biomes</SelectItem>
<SelectItem value="amplified">Amplified</SelectItem>
<SelectItem value="buffet">Buffet (Single Biome)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field
id="generatorSettings"
label="Generator Settings"
tooltip="JSON configuration for custom world generation. Advanced users only."
>
<Textarea
id="generatorSettings"
value={formData.generatorSettings || ""}
onChange={(event) => updateField("generatorSettings", event.target.value)}
placeholder={'{"structures": {...}, "layers": [...]}'}
rows={3}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
id="spawnProtection"
label="Spawn Protection (blocks)"
tooltip="Radius around spawn where only ops can build. Set to 0 to disable."
>
<Input
id="spawnProtection"
type="number"
value={formData.spawnProtection}
onChange={(event) => updateField("spawnProtection", event.target.value)}
min="0"
/>
</Field>
<Field
id="simulationDistance"
label="Simulation Distance"
tooltip="Distance in chunks where game logic runs (redstone, crops, etc.)"
>
<Input
id="simulationDistance"
type="number"
value={formData.simulationDistance}
onChange={(event) => updateField("simulationDistance", event.target.value)}
min="3"
max="32"
/>
</Field>
</div>
<div className="space-y-3">
<CheckboxField id="spawnAnimals" checked={formData.spawnAnimals} onCheckedChange={(value) => updateField("spawnAnimals", value)}>
Spawn Animals
</CheckboxField>
<CheckboxField id="spawnMonsters" checked={formData.spawnMonsters} onCheckedChange={(value) => updateField("spawnMonsters", value)}>
Spawn Monsters
</CheckboxField>
<CheckboxField id="spawnNpcs" checked={formData.spawnNpcs} onCheckedChange={(value) => updateField("spawnNpcs", value)}>
Spawn NPCs (Villagers)
</CheckboxField>
</div>
</TabsContent>
);
}
@@ -0,0 +1,97 @@
"use client";
import type { ConnectionInfo } from "@minikura/api";
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
type ConnectionInfoCellProps = {
serverId: string;
type: "normal" | "proxy";
};
export function ConnectionInfoCell({ serverId, type }: ConnectionInfoCellProps) {
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
const fetchConnectionInfo = async () => {
try {
const reverseProxyApi = getReverseProxyApi();
const endpoint =
type === "normal"
? api.api.servers({ id: serverId })["connection-info"]
: reverseProxyApi({ id: serverId })["connection-info"];
const res = await endpoint.get();
if (res.data) {
setConnectionInfo(res.data as ConnectionInfo);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchConnectionInfo();
}, [serverId, type]);
const handleCopy = async () => {
if (connectionInfo?.connectionString) {
await navigator.clipboard.writeText(connectionInfo.connectionString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (loading) {
return <span className="text-muted-foreground text-xs">Loading...</span>;
}
if (!connectionInfo) {
return <span className="text-muted-foreground text-xs">N/A</span>;
}
return (
<TooltipProvider>
<div className="flex flex-col gap-1">
<Badge variant="secondary" className="w-fit text-xs">
{connectionInfo.type}
</Badge>
{connectionInfo.connectionString && (
<div className="flex items-center gap-1">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{connectionInfo.connectionString}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={handleCopy}
aria-label="Copy connection string"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
</TooltipContent>
</Tooltip>
</div>
)}
{connectionInfo.note && (
<p className="text-xs text-muted-foreground">{connectionInfo.note}</p>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,52 @@
"use client";
import type { PodInfo } from "@minikura/api";
import { useEffect, useState } from "react";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api-client";
type ServerStatusCellProps = {
serverId: string;
type: "normal" | "proxy";
};
export function ServerStatusCell({ serverId, type }: ServerStatusCellProps) {
const [pods, setPods] = useState<PodInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPods = async () => {
try {
const endpoint =
type === "normal"
? api.api.k8s.servers({ serverId }).pods.get
: api.api.k8s["reverse-proxy"]({ serverId }).pods.get;
const res = await endpoint();
if (res.data) {
setPods(res.data as PodInfo[]);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchPods();
}, [serverId, type]);
if (loading) {
return <StatusBadge pulse>Checking</StatusBadge>;
}
if (pods.length === 0) {
return <StatusBadge>No pods</StatusBadge>;
}
const allRunning = pods.every((pod) => pod.status === "Running");
const readyCount = pods.filter((pod) => pod.ready === "1/1").length;
return (
<StatusBadge tone={allRunning ? "success" : "warning"}>
{readyCount}/{pods.length} ready
</StatusBadge>
);
}
@@ -0,0 +1,155 @@
import type { NormalServer, ReverseProxyServer } from "@minikura/api";
import { Pencil, Trash2 } from "lucide-react";
import { DataTable, type DataTableColumn } from "@/components/data-table";
import { TableActions } from "@/components/section-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConnectionInfoCell } from "./connection-info-cell";
import { ServerStatusCell } from "./server-status-cell";
type ServerTableProps =
| {
type: "normal";
servers: NormalServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
| {
type: "proxy";
servers: ReverseProxyServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
};
function RowActions({
id,
kind,
onEdit,
onDelete,
}: {
id: string;
kind: string;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<TableActions>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
aria-label={`Edit ${kind} ${id}`}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
aria-label={`Delete ${kind} ${id}`}
>
<Trash2 />
</Button>
</TableActions>
);
}
export function ServerTable(props: ServerTableProps) {
if (props.type === "normal") {
const columns: readonly DataTableColumn<NormalServer>[] = [
{ id: "id", header: "ID", cell: (server) => server.id, className: "font-bold" },
{
id: "status",
header: "Status",
cell: (server) => <ServerStatusCell serverId={server.id} type="normal" />,
},
{
id: "storage",
header: "Storage",
cell: (server) => <Badge variant="outline">{server.type}</Badge>,
},
{
id: "software",
header: "Software",
cell: (server) => <Badge variant="secondary">{server.jar_type || "VANILLA"}</Badge>,
},
{
id: "version",
header: "Version",
cell: (server) => server.minecraft_version || "LATEST",
className: "text-muted-foreground",
},
{ id: "memory", header: "Memory", cell: (server) => `${server.memory || 1024} MB` },
{
id: "network",
header: "Network",
cell: (server) => <ConnectionInfoCell serverId={server.id} type="normal" />,
},
{
id: "description",
header: "Description",
cell: (server) => server.description || "-",
className: "max-w-64 truncate text-muted-foreground",
},
{
id: "actions",
header: "Actions",
headerClassName: "text-right",
className: "text-right",
cell: (server) => (
<RowActions
id={server.id}
kind="server"
onEdit={props.onEdit}
onDelete={props.onDelete}
/>
),
},
];
return <DataTable data={props.servers} columns={columns} getRowKey={(server) => server.id} />;
}
const columns: readonly DataTableColumn<ReverseProxyServer>[] = [
{ id: "id", header: "ID", cell: (server) => server.id, className: "font-bold" },
{
id: "status",
header: "Status",
cell: (server) => <ServerStatusCell serverId={server.id} type="proxy" />,
},
{
id: "type",
header: "Type",
cell: (server) => <Badge variant="outline">{server.type}</Badge>,
},
{
id: "external",
header: "External",
cell: (server) => `${server.external_address}:${server.external_port}`,
},
{ id: "port", header: "Listen port", cell: (server) => server.listen_port },
{ id: "memory", header: "Memory", cell: (server) => `${server.memory} MB` },
{
id: "network",
header: "Network",
cell: (server) => <ConnectionInfoCell serverId={server.id} type="proxy" />,
},
{
id: "description",
header: "Description",
cell: (server) => server.description || "-",
className: "max-w-64 truncate text-muted-foreground",
},
{
id: "actions",
header: "Actions",
headerClassName: "text-right",
className: "text-right",
cell: (server) => (
<RowActions id={server.id} kind="proxy" onEdit={props.onEdit} onDelete={props.onDelete} />
),
},
];
return <DataTable data={props.servers} columns={columns} getRowKey={(server) => server.id} />;
}
+5 -1
View File
@@ -9,8 +9,10 @@ export function useServerList() {
const [normalServers, setNormalServers] = useState<NormalServer[]>([]);
const [reverseProxies, setReverseProxies] = useState<ReverseProxyServer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchServers = useCallback(async () => {
setError(null);
try {
const [normalRes, proxyRes] = await Promise.all([
api.api.servers.get(),
@@ -23,7 +25,8 @@ export function useServerList() {
if (proxyRes.data) {
setReverseProxies(proxyRes.data as unknown as ReverseProxyServer[]);
}
} catch (_error) {
} catch (error) {
setError(error instanceof Error ? error.message : "Failed to load servers");
} finally {
setLoading(false);
}
@@ -49,6 +52,7 @@ export function useServerList() {
normalServers,
reverseProxies,
loading,
error,
refresh: fetchServers,
deleteServer,
};
+368
View File
@@ -0,0 +1,368 @@
import type { CreateServerRequest, NormalServer } from "@minikura/api";
import type { EnvVar, ServerFormData, ServerType, ServiceType } from "@/components/server-form";
type EnvironmentVariable = { key: string; value: string };
export type CommonServerRequestFields = Omit<CreateServerRequest, "id" | "type">;
const MANAGED_ENVIRONMENT_KEYS = new Set([
"EULA",
"TYPE",
"VERSION",
"CUSTOM_SERVER",
"MOTD",
"DIFFICULTY",
"MODE",
"MAX_PLAYERS",
"PVP",
"ONLINE_MODE",
"ALLOW_FLIGHT",
"ENABLE_COMMAND_BLOCK",
"SPAWN_PROTECTION",
"VIEW_DISTANCE",
"SIMULATION_DISTANCE",
"LEVEL",
"SEED",
"LEVEL_TYPE",
"GENERATOR_SETTINGS",
"HARDCORE",
"SPAWN_ANIMALS",
"SPAWN_MONSTERS",
"SPAWN_NPCS",
"ENABLE_WHITELIST",
"WHITELIST",
"WHITELIST_FILE",
"OPS",
"OPS_FILE",
"USE_AIKAR_FLAGS",
"USE_MEOWICE_FLAGS",
"JVM_OPTS",
"JVM_XX_OPTS",
"JVM_DD_OPTS",
"ENABLE_JMX",
"RESOURCE_PACK",
"RESOURCE_PACK_SHA1",
"RESOURCE_PACK_ENFORCE",
"ENABLE_RCON",
"RCON_PASSWORD",
"RCON_PORT",
"RCON_CMDS_STARTUP",
"RCON_CMDS_ON_CONNECT",
"RCON_CMDS_FIRST_CONNECT",
"RCON_CMDS_ON_DISCONNECT",
"RCON_CMDS_LAST_DISCONNECT",
"ENABLE_QUERY",
"QUERY_PORT",
"ENABLE_AUTOPAUSE",
"AUTOPAUSE_TIMEOUT_EST",
"AUTOPAUSE_TIMEOUT_INIT",
"AUTOPAUSE_TIMEOUT_KN",
"AUTOPAUSE_PERIOD",
"AUTOPAUSE_KNOCK_INTERFACE",
"ENABLE_AUTOSTOP",
"AUTOSTOP_TIMEOUT_EST",
"AUTOSTOP_TIMEOUT_INIT",
"AUTOSTOP_PERIOD",
"PLUGINS",
"REMOVE_OLD_PLUGINS",
"SPIGET_RESOURCES",
"PAPER_BUILD",
"TZ",
"UID",
"GID",
"STOP_DURATION",
"ICON",
]);
export const normalizeDifficulty = (value?: string | null): ServerFormData["difficulty"] => {
const normalized = value?.toLowerCase();
if (
normalized === "peaceful" ||
normalized === "easy" ||
normalized === "normal" ||
normalized === "hard"
) {
return normalized;
}
return "easy";
};
export const normalizeMode = (value?: string | null): ServerFormData["mode"] => {
const normalized = value?.toLowerCase();
if (
normalized === "survival" ||
normalized === "creative" ||
normalized === "adventure" ||
normalized === "spectator"
) {
return normalized;
}
return "survival";
};
export const normalizeServiceType = (value?: string | null): ServiceType => {
if (value === "NODE_PORT" || value === "LOAD_BALANCER") {
return value;
}
return "CLUSTER_IP";
};
export const normalizeServerType = (value?: string | null): ServerType => {
if (
value === "VANILLA" ||
value === "PAPER" ||
value === "SPIGOT" ||
value === "PURPUR" ||
value === "FABRIC" ||
value === "CUSTOM"
) {
return value;
}
return "PAPER";
};
export const toApiDifficulty = (
value: ServerFormData["difficulty"]
): Uppercase<ServerFormData["difficulty"]> => {
return value.toUpperCase() as Uppercase<ServerFormData["difficulty"]>;
};
export const toApiMode = (value: ServerFormData["mode"]): Uppercase<ServerFormData["mode"]> => {
return value.toUpperCase() as Uppercase<ServerFormData["mode"]>;
};
export const toManagedEnvironmentVariables = (data: ServerFormData): Record<string, string> => {
const environment: Record<string, string> = {};
if (data.allowFlight) environment.ALLOW_FLIGHT = String(data.allowFlight);
if (data.enableCommandBlock) {
environment.ENABLE_COMMAND_BLOCK = String(data.enableCommandBlock);
}
if (data.spawnProtection) environment.SPAWN_PROTECTION = data.spawnProtection;
if (data.viewDistance) environment.VIEW_DISTANCE = data.viewDistance;
if (data.simulationDistance) environment.SIMULATION_DISTANCE = data.simulationDistance;
if (data.levelName) environment.LEVEL = data.levelName;
if (data.levelSeed) environment.SEED = data.levelSeed;
if (data.levelType) environment.LEVEL_TYPE = data.levelType;
if (data.generatorSettings) environment.GENERATOR_SETTINGS = data.generatorSettings;
if (data.hardcore) environment.HARDCORE = String(data.hardcore);
if (data.spawnAnimals !== undefined) environment.SPAWN_ANIMALS = String(data.spawnAnimals);
if (data.spawnMonsters !== undefined) environment.SPAWN_MONSTERS = String(data.spawnMonsters);
if (data.spawnNpcs !== undefined) environment.SPAWN_NPCS = String(data.spawnNpcs);
if (data.enableWhitelist) environment.ENABLE_WHITELIST = String(data.enableWhitelist);
if (data.whitelist) environment.WHITELIST = data.whitelist;
if (data.whitelistFile) environment.WHITELIST_FILE = data.whitelistFile;
if (data.ops) environment.OPS = data.ops;
if (data.opsFile) environment.OPS_FILE = data.opsFile;
if (data.jvmXxOpts) environment.JVM_XX_OPTS = data.jvmXxOpts;
if (data.jvmDdOpts) environment.JVM_DD_OPTS = data.jvmDdOpts;
if (data.enableJmx) environment.ENABLE_JMX = String(data.enableJmx);
if (data.resourcePack) environment.RESOURCE_PACK = data.resourcePack;
if (data.resourcePackSha1) environment.RESOURCE_PACK_SHA1 = data.resourcePackSha1;
if (data.resourcePackEnforce) {
environment.RESOURCE_PACK_ENFORCE = String(data.resourcePackEnforce);
}
if (data.enableRcon !== undefined) environment.ENABLE_RCON = String(data.enableRcon);
if (data.rconPassword) environment.RCON_PASSWORD = data.rconPassword;
if (data.rconPort) environment.RCON_PORT = data.rconPort;
if (data.rconCmdsStartup) environment.RCON_CMDS_STARTUP = data.rconCmdsStartup;
if (data.rconCmdsOnConnect) environment.RCON_CMDS_ON_CONNECT = data.rconCmdsOnConnect;
if (data.rconCmdsFirstConnect) {
environment.RCON_CMDS_FIRST_CONNECT = data.rconCmdsFirstConnect;
}
if (data.rconCmdsOnDisconnect) {
environment.RCON_CMDS_ON_DISCONNECT = data.rconCmdsOnDisconnect;
}
if (data.rconCmdsLastDisconnect) {
environment.RCON_CMDS_LAST_DISCONNECT = data.rconCmdsLastDisconnect;
}
if (data.enableQuery !== undefined) environment.ENABLE_QUERY = String(data.enableQuery);
if (data.queryPort) environment.QUERY_PORT = data.queryPort;
if (data.enableAutopause) environment.ENABLE_AUTOPAUSE = String(data.enableAutopause);
if (data.autopauseTimeoutEst) environment.AUTOPAUSE_TIMEOUT_EST = data.autopauseTimeoutEst;
if (data.autopauseTimeoutInit) environment.AUTOPAUSE_TIMEOUT_INIT = data.autopauseTimeoutInit;
if (data.autopauseTimeoutKn) environment.AUTOPAUSE_TIMEOUT_KN = data.autopauseTimeoutKn;
if (data.autopausePeriod) environment.AUTOPAUSE_PERIOD = data.autopausePeriod;
if (data.autopauseKnockInterface) {
environment.AUTOPAUSE_KNOCK_INTERFACE = data.autopauseKnockInterface;
}
if (data.enableAutostop) environment.ENABLE_AUTOSTOP = String(data.enableAutostop);
if (data.autostopTimeoutEst) environment.AUTOSTOP_TIMEOUT_EST = data.autostopTimeoutEst;
if (data.autostopTimeoutInit) environment.AUTOSTOP_TIMEOUT_INIT = data.autostopTimeoutInit;
if (data.autostopPeriod) environment.AUTOSTOP_PERIOD = data.autostopPeriod;
if (data.plugins) environment.PLUGINS = data.plugins;
if (data.removeOldPlugins) environment.REMOVE_OLD_PLUGINS = String(data.removeOldPlugins);
if (data.spigetResources) environment.SPIGET_RESOURCES = data.spigetResources;
if (data.paperBuild) environment.PAPER_BUILD = data.paperBuild;
if (data.type === "CUSTOM" && data.customJarUrl) {
environment.CUSTOM_SERVER = data.customJarUrl;
environment.VERSION = "";
}
if (data.timezone) environment.TZ = data.timezone;
if (data.uid) environment.UID = data.uid;
if (data.gid) environment.GID = data.gid;
if (data.stopDuration) environment.STOP_DURATION = data.stopDuration;
if (data.serverIcon) environment.ICON = data.serverIcon;
environment.EULA = String(data.eula);
environment.TYPE = data.type;
if (data.type !== "CUSTOM" && data.version) {
environment.VERSION = data.version;
}
if (data.type === "CUSTOM" && !data.customJarUrl) {
throw new Error("Custom jar URL is required for custom servers");
}
return environment;
};
export const filterCustomEnvironmentVariables = (
environmentVariables?: EnvironmentVariable[]
): EnvVar[] => {
return (environmentVariables ?? [])
.filter(({ key }) => !MANAGED_ENVIRONMENT_KEYS.has(key))
.map(({ key, value }) => ({ key, value }));
};
export const toCommonServerRequestFields = (data: ServerFormData): CommonServerRequestFields => {
const environment = toManagedEnvironmentVariables(data);
for (const { key, value } of data.envVars) {
if (key && value) {
environment[key] = value;
}
}
return {
description: data.description.trim() || null,
listen_port: Number(data.listenPort),
service_type: data.serviceType,
node_port: data.serviceType === "NODE_PORT" && data.nodePort ? Number(data.nodePort) : null,
env_variables: Object.entries(environment).map(([key, value]) => ({ key, value })),
memory: data.memoryLimit ? Number(data.memoryLimit) : undefined,
memory_request: data.memoryRequest ? Number(data.memoryRequest) : undefined,
cpu_request: data.cpuRequest || undefined,
cpu_limit: data.cpuLimit || undefined,
jar_type: data.type === "CUSTOM" ? "VANILLA" : data.type,
minecraft_version: data.type === "CUSTOM" ? undefined : data.version || "LATEST",
jvm_opts: data.jvmOpts || undefined,
use_aikar_flags: data.useAikarFlags || undefined,
use_meowice_flags: data.useMeowiceFlags || undefined,
difficulty: toApiDifficulty(data.difficulty),
game_mode: toApiMode(data.mode),
max_players: data.maxPlayers ? Number(data.maxPlayers) : undefined,
pvp: data.pvp,
online_mode: data.onlineMode,
motd: data.motd,
level_seed: data.levelSeed,
level_type: data.levelType,
};
};
export const toInitialServerFormData = (server: NormalServer): Partial<ServerFormData> => {
const environment = Object.fromEntries(
server.env_variables.map(({ key, value }) => [key, value])
);
return {
id: server.id,
description: server.description || "",
memoryLimit: String(server.memory || 2048),
memoryRequest: String(server.memory_request ?? 1024),
cpuRequest: server.cpu_request || "500m",
cpuLimit: server.cpu_limit || "2",
type: normalizeServerType(environment.TYPE || server.jar_type),
version: environment.VERSION || server.minecraft_version || "",
customJarUrl: environment.CUSTOM_SERVER || undefined,
eula: environment.EULA === "true",
listenPort: String(server.listen_port || 25565),
serviceType: normalizeServiceType(server.service_type),
nodePort: server.node_port ? String(server.node_port) : undefined,
motd: environment.MOTD || server.motd || undefined,
difficulty: normalizeDifficulty(environment.DIFFICULTY || server.difficulty),
mode: normalizeMode(environment.MODE || server.game_mode),
maxPlayers: environment.MAX_PLAYERS || String(server.max_players || 20),
pvp: environment.PVP ? environment.PVP === "true" : (server.pvp ?? true),
onlineMode: environment.ONLINE_MODE
? environment.ONLINE_MODE === "true"
: (server.online_mode ?? true),
allowFlight: environment.ALLOW_FLIGHT === "true",
enableCommandBlock: environment.ENABLE_COMMAND_BLOCK === "true",
spawnProtection: environment.SPAWN_PROTECTION || "16",
viewDistance: environment.VIEW_DISTANCE || "10",
simulationDistance: environment.SIMULATION_DISTANCE || "10",
levelName: environment.LEVEL || "world",
levelSeed: environment.SEED || server.level_seed || undefined,
levelType: environment.LEVEL_TYPE || server.level_type || undefined,
generatorSettings: environment.GENERATOR_SETTINGS || undefined,
hardcore: environment.HARDCORE === "true",
spawnAnimals: environment.SPAWN_ANIMALS !== "false",
spawnMonsters: environment.SPAWN_MONSTERS !== "false",
spawnNpcs: environment.SPAWN_NPCS !== "false",
enableWhitelist: environment.ENABLE_WHITELIST === "true",
whitelist: environment.WHITELIST || undefined,
whitelistFile: environment.WHITELIST_FILE || undefined,
ops: environment.OPS || undefined,
opsFile: environment.OPS_FILE || undefined,
useAikarFlags: environment.USE_AIKAR_FLAGS === "true" || server.use_aikar_flags || false,
useMeowiceFlags: environment.USE_MEOWICE_FLAGS === "true" || server.use_meowice_flags || false,
jvmOpts: environment.JVM_OPTS || server.jvm_opts || undefined,
jvmXxOpts: environment.JVM_XX_OPTS || undefined,
jvmDdOpts: environment.JVM_DD_OPTS || undefined,
enableJmx: environment.ENABLE_JMX === "true",
resourcePack: environment.RESOURCE_PACK || undefined,
resourcePackSha1: environment.RESOURCE_PACK_SHA1 || undefined,
resourcePackEnforce: environment.RESOURCE_PACK_ENFORCE === "true",
enableRcon: environment.ENABLE_RCON !== "false",
rconPassword: environment.RCON_PASSWORD || undefined,
rconPort: environment.RCON_PORT || "25575",
rconCmdsStartup: environment.RCON_CMDS_STARTUP || undefined,
rconCmdsOnConnect: environment.RCON_CMDS_ON_CONNECT || undefined,
rconCmdsFirstConnect: environment.RCON_CMDS_FIRST_CONNECT || undefined,
rconCmdsOnDisconnect: environment.RCON_CMDS_ON_DISCONNECT || undefined,
rconCmdsLastDisconnect: environment.RCON_CMDS_LAST_DISCONNECT || undefined,
enableQuery: environment.ENABLE_QUERY === "true",
queryPort: environment.QUERY_PORT || "25565",
enableAutopause: environment.ENABLE_AUTOPAUSE === "true",
autopauseTimeoutEst: environment.AUTOPAUSE_TIMEOUT_EST || "3600",
autopauseTimeoutInit: environment.AUTOPAUSE_TIMEOUT_INIT || "600",
autopauseTimeoutKn: environment.AUTOPAUSE_TIMEOUT_KN || "120",
autopausePeriod: environment.AUTOPAUSE_PERIOD || "10",
autopauseKnockInterface: environment.AUTOPAUSE_KNOCK_INTERFACE || "eth0",
enableAutostop: environment.ENABLE_AUTOSTOP === "true",
autostopTimeoutEst: environment.AUTOSTOP_TIMEOUT_EST || "3600",
autostopTimeoutInit: environment.AUTOSTOP_TIMEOUT_INIT || "1800",
autostopPeriod: environment.AUTOSTOP_PERIOD || "10",
plugins: environment.PLUGINS || undefined,
removeOldPlugins: environment.REMOVE_OLD_PLUGINS === "true",
spigetResources: environment.SPIGET_RESOURCES || undefined,
paperBuild: environment.PAPER_BUILD || undefined,
timezone: environment.TZ || "UTC",
uid: environment.UID || "1000",
gid: environment.GID || "1000",
stopDuration: environment.STOP_DURATION || "60",
serverIcon: environment.ICON || undefined,
envVars: filterCustomEnvironmentVariables(server.env_variables),
};
};