mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
✨ feat: refine server management workflows
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+59
-1280
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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user