feat: refine server management workflows

This commit is contained in:
2026-08-13 02:19:26 +07:00
parent 3a09f0b571
commit bd473863f3
22 changed files with 2069 additions and 2208 deletions
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { Info } from "lucide-react";
import type * as React from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cn";
export function HelpTooltip({ content }: { content: React.ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-muted-foreground hover:text-foreground">
<Info className="size-4" />
<span className="sr-only">More information</span>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-64">{content}</TooltipContent>
</Tooltip>
);
}
export function FormNotice({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("rounded-sm border border-dashed p-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
import { Plus, Trash2 } from "lucide-react";
import { HelpTooltip } from "@/components/form-controls";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
interface AdvancedPanelProps extends ServerFormPanelProps {
addEnvVar: () => void;
removeEnvVar: (index: number) => void;
updateEnvVar: (index: number, field: "key" | "value", value: string) => void;
}
export function AdvancedPanel({
formData,
updateField,
addEnvVar,
removeEnvVar,
updateEnvVar,
}: AdvancedPanelProps) {
return (
<TabsContent value="advanced" className="space-y-4 mt-4">
<div className="grid grid-cols-3 gap-4">
<Field id="timezone" label="Timezone" tooltip="Timezone for server logs and scheduling (e.g., America/New_York)">
<Input
id="timezone"
value={formData.timezone}
onChange={(event) => updateField("timezone", event.target.value)}
placeholder="UTC"
/>
</Field>
<Field id="uid" label="User ID (UID)" tooltip="Linux user ID for the Minecraft process">
<Input
id="uid"
value={formData.uid}
onChange={(event) => updateField("uid", event.target.value)}
placeholder="1000"
/>
</Field>
<Field id="gid" label="Group ID (GID)" tooltip="Linux group ID for the Minecraft process">
<Input
id="gid"
value={formData.gid}
onChange={(event) => updateField("gid", event.target.value)}
placeholder="1000"
/>
</Field>
</div>
<Field
id="stopDuration"
label="Graceful Shutdown Timeout (seconds)"
tooltip="How long to wait for graceful shutdown before forcing stop"
>
<Input
id="stopDuration"
type="number"
value={formData.stopDuration}
onChange={(event) => updateField("stopDuration", event.target.value)}
min="1"
/>
</Field>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>
Custom Environment Variables
<HelpTooltip content="Additional environment variables to pass to the container" />
</Label>
<Button type="button" variant="outline" size="sm" onClick={addEnvVar}>
<Plus className="h-4 w-4 mr-2" />
Add Variable
</Button>
</div>
{formData.envVars.length > 0 && (
<div className="space-y-3">
{formData.envVars.map((envVar, index) => (
<div key={envVar.id} className="flex gap-2">
<Input
placeholder="KEY"
value={envVar.key}
onChange={(event) => updateEnvVar(index, "key", event.target.value)}
className="flex-1 font-mono"
/>
<Input
placeholder="value"
value={envVar.value}
onChange={(event) => updateEnvVar(index, "value", event.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeEnvVar(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</TabsContent>
);
}
@@ -0,0 +1,194 @@
import { FormNotice } from "@/components/form-controls";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function AutomationPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="automation" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Automation hooks depend on container scripts; custom jars may ignore them.
</FormNotice>
)}
<Accordion type="multiple" className="w-full">
<AccordionItem value="rcon">
<AccordionTrigger>RCON Configuration</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableRcon"
checked={formData.enableRcon}
onCheckedChange={(value) => updateField("enableRcon", value)}
tooltip="Remote console for server management. Enabled by default for graceful shutdown."
>
Enable RCON
</CheckboxField>
{formData.enableRcon && (
<>
<div className="grid grid-cols-2 gap-4">
<Field id="rconPassword" label="RCON Password">
<Input
id="rconPassword"
type="password"
value={formData.rconPassword || ""}
onChange={(event) => updateField("rconPassword", event.target.value)}
placeholder="Auto-generated if empty"
/>
</Field>
<Field id="rconPort" label="RCON Port">
<Input
id="rconPort"
type="number"
value={formData.rconPort}
onChange={(event) => updateField("rconPort", event.target.value)}
min="1"
max="65535"
/>
</Field>
</div>
<Field
id="rconCmdsStartup"
label="RCON Commands on Startup"
tooltip="Semicolon-separated commands to run when server starts"
>
<Textarea
id="rconCmdsStartup"
value={formData.rconCmdsStartup || ""}
onChange={(event) => updateField("rconCmdsStartup", event.target.value)}
placeholder="say Server starting...;gamerule doDaylightCycle false"
rows={2}
/>
</Field>
<Field id="rconCmdsOnConnect" label="Commands on Player Connect">
<Textarea
id="rconCmdsOnConnect"
value={formData.rconCmdsOnConnect || ""}
onChange={(event) => updateField("rconCmdsOnConnect", event.target.value)}
placeholder="tell {{player}} Welcome to the server!"
rows={2}
/>
</Field>
</>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="query">
<AccordionTrigger>Query Protocol</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableQuery"
checked={formData.enableQuery}
onCheckedChange={(value) => updateField("enableQuery", value)}
tooltip="Allows external tools to query server status and player list"
>
Enable Query Protocol
</CheckboxField>
{formData.enableQuery && (
<Field id="queryPort" label="Query Port">
<Input
id="queryPort"
type="number"
value={formData.queryPort}
onChange={(event) => updateField("queryPort", event.target.value)}
min="1"
max="65535"
/>
</Field>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="autopause">
<AccordionTrigger>Auto-Pause</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableAutopause"
checked={formData.enableAutopause}
onCheckedChange={(value) => updateField("enableAutopause", value)}
tooltip="Automatically pauses the server when no players are online to save resources"
>
Enable Auto-Pause
</CheckboxField>
{formData.enableAutopause && (
<>
<div className="grid grid-cols-2 gap-4">
<Field id="autopauseTimeoutEst" label="Timeout After Disconnect (seconds)">
<Input
id="autopauseTimeoutEst"
type="number"
value={formData.autopauseTimeoutEst}
onChange={(event) => updateField("autopauseTimeoutEst", event.target.value)}
min="0"
/>
</Field>
<Field id="autopauseTimeoutInit" label="Timeout After Startup (seconds)">
<Input
id="autopauseTimeoutInit"
type="number"
value={formData.autopauseTimeoutInit}
onChange={(event) => updateField("autopauseTimeoutInit", event.target.value)}
min="0"
/>
</Field>
</div>
<Field id="autopauseKnockInterface" label="Network Interface">
<Input
id="autopauseKnockInterface"
value={formData.autopauseKnockInterface}
onChange={(event) => updateField("autopauseKnockInterface", event.target.value)}
placeholder="eth0"
/>
</Field>
</>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="autostop">
<AccordionTrigger>Auto-Stop</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<CheckboxField
id="enableAutostop"
checked={formData.enableAutostop}
onCheckedChange={(value) => updateField("enableAutostop", value)}
tooltip="Automatically stops the server when no players are online"
>
Enable Auto-Stop
</CheckboxField>
{formData.enableAutostop && (
<div className="grid grid-cols-2 gap-4">
<Field id="autostopTimeoutEst" label="Timeout After Player Departure (seconds)">
<Input
id="autostopTimeoutEst"
type="number"
value={formData.autostopTimeoutEst}
onChange={(event) => updateField("autostopTimeoutEst", event.target.value)}
min="0"
/>
</Field>
<Field id="autostopTimeoutInit" label="Timeout After Launch (seconds)">
<Input
id="autostopTimeoutInit"
type="number"
value={formData.autostopTimeoutInit}
onChange={(event) => updateField("autostopTimeoutInit", event.target.value)}
min="0"
/>
</Field>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
);
}
@@ -0,0 +1,115 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function BasicPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="basic" className="space-y-4 mt-4">
<Field id="id" label="Server ID *">
<Input
id="id"
value={formData.id}
onChange={(event) => updateField("id", event.target.value)}
placeholder="my-server"
required
/>
</Field>
<Field id="description" label="Description">
<Textarea
id="description"
value={formData.description}
onChange={(event) => updateField("description", event.target.value)}
placeholder="A brief description of this server"
rows={2}
/>
</Field>
<div className="grid grid-cols-4 gap-4">
<Field
id="memoryRequest"
label="Memory Request (MB) *"
tooltip="Kubernetes guaranteed memory. Java heap sizes from the limit."
>
<Input
id="memoryRequest"
type="number"
value={formData.memoryRequest}
onChange={(event) => updateField("memoryRequest", event.target.value)}
min="256"
required
/>
</Field>
<Field
id="memoryLimit"
label="Memory Limit (MB) *"
tooltip="Kubernetes max memory. Java heap scales from this."
>
<Input
id="memoryLimit"
type="number"
value={formData.memoryLimit}
onChange={(event) => updateField("memoryLimit", event.target.value)}
min="256"
required
/>
</Field>
<Field
id="cpuRequest"
label="CPU Request *"
tooltip="Guaranteed CPU allocation (e.g., 500m = 0.5 cores)"
>
<Input
id="cpuRequest"
value={formData.cpuRequest}
onChange={(event) => updateField("cpuRequest", event.target.value)}
placeholder="500m"
/>
</Field>
<Field id="cpuLimit" label="CPU Limit *" tooltip="Maximum CPU the server can use">
<Input
id="cpuLimit"
value={formData.cpuLimit}
onChange={(event) => updateField("cpuLimit", event.target.value)}
placeholder="2"
/>
</Field>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced-memory">
<AccordionTrigger>Java Heap Overrides</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm text-muted-foreground">
Heap sizes are derived from the memory limit. Override only if needed.
</AccordionContent>
</AccordionItem>
</Accordion>
<CheckboxField
id="eula"
checked={formData.eula}
onCheckedChange={(checked) => updateField("eula", checked)}
labelClassName="cursor-pointer"
>
I accept the{" "}
<a
href="https://www.minecraft.net/eula"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Minecraft EULA
</a>{" "}
*
</CheckboxField>
</TabsContent>
);
}
@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import { HelpTooltip } from "@/components/form-controls";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
interface FieldProps {
id: string;
label: ReactNode;
tooltip?: string;
children: ReactNode;
}
export function Field({ id, label, tooltip, children }: FieldProps) {
return (
<div className="space-y-2">
<Label htmlFor={id}>
{label}
{tooltip && <HelpTooltip content={tooltip} />}
</Label>
{children}
</div>
);
}
interface CheckboxFieldProps {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
children: ReactNode;
tooltip?: string;
labelClassName?: string;
}
export function CheckboxField({
id,
checked,
onCheckedChange,
children,
tooltip,
labelClassName,
}: CheckboxFieldProps) {
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(value) => onCheckedChange(value as boolean)}
/>
<Label htmlFor={id} className={labelClassName}>
{children}
{tooltip && <HelpTooltip content={tooltip} />}
</Label>
</div>
);
}
@@ -0,0 +1,50 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="mods" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
)}
<Field id="plugins" label="Plugins" tooltip="Comma-separated list of plugin URLs or filenames">
<Textarea
id="plugins"
value={formData.plugins || ""}
onChange={(event) => updateField("plugins", event.target.value)}
placeholder="https://example.com/plugin1.jar,plugin2.jar"
rows={3}
/>
<p className="text-sm text-muted-foreground">
For Paper/Spigot/Bukkit servers. URLs or local filenames.
</p>
</Field>
<Field
id="spigetResources"
label="Spiget Resource IDs"
tooltip="Comma-separated Spiget resource IDs to auto-download from SpigotMC"
>
<Input
id="spigetResources"
value={formData.spigetResources || ""}
onChange={(event) => updateField("spigetResources", event.target.value)}
placeholder="1234,5678,9012"
/>
</Field>
<div className="space-y-3">
<CheckboxField
id="removeOldPlugins"
checked={formData.removeOldPlugins}
onCheckedChange={(value) => updateField("removeOldPlugins", value)}
tooltip="Automatically remove old versions of plugins when updating"
>
Remove Old Plugins
</CheckboxField>
</div>
</TabsContent>
);
}
@@ -0,0 +1,66 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsContent } from "@/components/ui/tabs";
import { Field } from "./fields";
import type { ServerFormPanelProps, ServiceType } from "./types";
export function NetworkPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="network" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Network settings apply at the container level and still work with custom jars.
</FormNotice>
)}
<div className="grid grid-cols-2 gap-4">
<Field id="listenPort" label="Listen Port">
<Input
id="listenPort"
type="number"
value={formData.listenPort}
onChange={(event) => updateField("listenPort", event.target.value)}
min="1"
max="65535"
/>
</Field>
<Field id="serviceType" label="Service Type" tooltip="How the server is exposed in Kubernetes">
<Select
value={formData.serviceType}
onValueChange={(value) => updateField("serviceType", value as ServiceType)}
>
<SelectTrigger id="serviceType"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="CLUSTER_IP">ClusterIP (Internal Only)</SelectItem>
<SelectItem value="NODE_PORT">NodePort (External Access)</SelectItem>
<SelectItem value="LOAD_BALANCER">LoadBalancer (Cloud/MetalLB)</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
{formData.serviceType === "NODE_PORT" && (
<Field
id="nodePort"
label="Node Port"
tooltip="Specific port on the node (30000-32767). Leave empty for auto-assignment."
>
<Input
id="nodePort"
type="number"
value={formData.nodePort || ""}
onChange={(event) => updateField("nodePort", event.target.value)}
placeholder="30000-32767"
min="30000"
max="32767"
/>
</Field>
)}
</TabsContent>
);
}
@@ -0,0 +1,74 @@
import { FormNotice } from "@/components/form-controls";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function PerformancePanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="performance" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Custom jars may not honor built-in tuning flags. Use custom env vars if required.
</FormNotice>
)}
<div className="space-y-3">
<CheckboxField
id="useAikarFlags"
checked={formData.useAikarFlags}
onCheckedChange={(value) => updateField("useAikarFlags", value)}
tooltip="Optimized G1GC garbage collection flags. Recommended for servers with 1GB+ RAM."
>
Use Aikar's Flags
</CheckboxField>
<CheckboxField
id="useMeowiceFlags"
checked={formData.useMeowiceFlags}
onCheckedChange={(value) => updateField("useMeowiceFlags", value)}
tooltip="Modern optimization flags for Java 17+. Based on Aikar's flags with improvements."
>
Use Meowice Flags
</CheckboxField>
<CheckboxField
id="enableJmx"
checked={formData.enableJmx}
onCheckedChange={(value) => updateField("enableJmx", value)}
tooltip="Enables remote JMX monitoring for advanced profiling"
>
Enable JMX
</CheckboxField>
</div>
<Field
id="jvmOpts"
label="Custom JVM Options"
tooltip="Space-separated JVM arguments (e.g., -Xms2G -Xmx4G)"
>
<Textarea
id="jvmOpts"
value={formData.jvmOpts || ""}
onChange={(event) => updateField("jvmOpts", event.target.value)}
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
rows={3}
/>
</Field>
<Field id="jvmXxOpts" label="JVM -XX Options" tooltip="Space-separated -XX JVM flags for advanced tuning">
<Textarea
id="jvmXxOpts"
value={formData.jvmXxOpts || ""}
onChange={(event) => updateField("jvmXxOpts", event.target.value)}
placeholder="+UnlockExperimentalVMOptions +UseZGC"
rows={2}
/>
</Field>
<Field id="jvmDdOpts" label="JVM -D System Properties" tooltip="Comma-separated key=value pairs for system properties">
<Textarea
id="jvmDdOpts"
value={formData.jvmDdOpts || ""}
onChange={(event) => updateField("jvmDdOpts", event.target.value)}
placeholder="property1=value1,property2=value2"
rows={2}
/>
</Field>
</TabsContent>
);
}
@@ -0,0 +1,74 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function PlayersPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="players" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>Some custom jars do not support managed whitelist/ops settings.</FormNotice>
)}
<CheckboxField
id="enableWhitelist"
checked={formData.enableWhitelist}
onCheckedChange={(value) => updateField("enableWhitelist", value)}
tooltip="Only whitelisted players can join the server"
>
Enable Whitelist
</CheckboxField>
{formData.enableWhitelist && (
<>
<Field
id="whitelist"
label="Whitelisted Players"
tooltip="Comma-separated list of usernames or UUIDs"
>
<Textarea
id="whitelist"
value={formData.whitelist || ""}
onChange={(event) => updateField("whitelist", event.target.value)}
placeholder="Player1,Player2,UUID-here"
rows={3}
/>
</Field>
<Field
id="whitelistFile"
label="Whitelist File URL"
tooltip="URL or path to a whitelist.json file. Overrides the whitelist field."
>
<Input
id="whitelistFile"
value={formData.whitelistFile || ""}
onChange={(event) => updateField("whitelistFile", event.target.value)}
placeholder="https://example.com/whitelist.json"
/>
</Field>
</>
)}
<Field
id="ops"
label="Operators (Ops)"
tooltip="Comma-separated list of usernames or UUIDs with full server permissions"
>
<Textarea
id="ops"
value={formData.ops || ""}
onChange={(event) => updateField("ops", event.target.value)}
placeholder="Admin1,Admin2"
rows={3}
/>
</Field>
<Field id="opsFile" label="Ops File URL">
<Input
id="opsFile"
value={formData.opsFile || ""}
onChange={(event) => updateField("opsFile", event.target.value)}
placeholder="https://example.com/ops.json"
/>
</Field>
</TabsContent>
);
}
@@ -0,0 +1,47 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import { TabsContent } from "@/components/ui/tabs";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function ResourcesPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="resources" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>Resource pack settings may not apply to custom jars.</FormNotice>
)}
<Field id="resourcePack" label="Resource Pack URL" tooltip="URL or path to a resource pack ZIP file">
<Input
id="resourcePack"
value={formData.resourcePack || ""}
onChange={(event) => updateField("resourcePack", event.target.value)}
placeholder="https://example.com/resourcepack.zip"
/>
</Field>
<Field id="resourcePackSha1" label="Resource Pack SHA1" tooltip="SHA1 checksum of the resource pack for verification">
<Input
id="resourcePackSha1"
value={formData.resourcePackSha1 || ""}
onChange={(event) => updateField("resourcePackSha1", event.target.value)}
placeholder="a1b2c3d4e5f6..."
/>
</Field>
<CheckboxField
id="resourcePackEnforce"
checked={formData.resourcePackEnforce}
onCheckedChange={(value) => updateField("resourcePackEnforce", value)}
tooltip="Require players to accept the resource pack to join"
>
Enforce Resource Pack
</CheckboxField>
<Field id="serverIcon" label="Server Icon URL" tooltip="URL or path to a server icon image (PNG, 64x64 recommended)">
<Input
id="serverIcon"
value={formData.serverIcon || ""}
onChange={(event) => updateField("serverIcon", event.target.value)}
placeholder="https://example.com/icon.png"
/>
</Field>
</TabsContent>
);
}
@@ -0,0 +1,177 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormData, ServerFormPanelProps, ServerType } from "./types";
const toMode = (value: string): ServerFormData["mode"] => {
if (value === "creative" || value === "adventure" || value === "spectator") return value;
return "survival";
};
const toDifficulty = (value: string): ServerFormData["difficulty"] => {
if (value === "peaceful" || value === "normal" || value === "hard") return value;
return "easy";
};
export function ServerPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="server" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
Custom jars may ignore built-in server settings. Configure extra values via custom
environment variables if needed.
</FormNotice>
)}
<Field
id="type"
label="Server Type *"
tooltip="Choose the server software (Vanilla, Paper, or Custom Jar)."
>
<Select
value={formData.type}
onValueChange={(value) => updateField("type", value as ServerType)}
>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="VANILLA">Vanilla (Official Mojang)</SelectItem>
<SelectItem value="PAPER">Paper (Recommended - High Performance)</SelectItem>
<SelectItem value="CUSTOM">Custom Jar</SelectItem>
</SelectContent>
</Select>
</Field>
{formData.type !== "CUSTOM" && (
<Field
id="version"
label="Minecraft Version"
tooltip="Use LATEST for newest stable, SNAPSHOT for snapshots, or specific version like 1.20.4"
>
<Input
id="version"
value={formData.version || ""}
onChange={(event) => updateField("version", event.target.value)}
placeholder="LATEST"
/>
</Field>
)}
{formData.type === "PAPER" && (
<Field id="paperBuild" label="Paper Build Number">
<Input
id="paperBuild"
value={formData.paperBuild || ""}
onChange={(event) => updateField("paperBuild", event.target.value)}
placeholder="Leave empty for latest"
/>
</Field>
)}
{formData.type === "CUSTOM" && (
<Field id="customJarUrl" label="Custom Server Jar URL/Path">
<Input
id="customJarUrl"
value={formData.customJarUrl || ""}
onChange={(event) => updateField("customJarUrl", event.target.value)}
placeholder="https://example.com/server.jar"
/>
<p className="text-sm text-muted-foreground">
Sets CUSTOM_SERVER for the itzg/minecraft-server image.
</p>
</Field>
)}
<Field id="motd" label="Message of the Day (MOTD)">
<Textarea
id="motd"
value={formData.motd || ""}
onChange={(event) => updateField("motd", event.target.value)}
placeholder="A Minecraft Server"
rows={2}
/>
<p className="text-sm text-muted-foreground">
Supports formatting codes like §l for bold, §c for red
</p>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field id="mode" label="Game Mode">
<Select value={formData.mode} onValueChange={(value) => updateField("mode", toMode(value))}>
<SelectTrigger id="mode"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="survival">Survival</SelectItem>
<SelectItem value="creative">Creative</SelectItem>
<SelectItem value="adventure">Adventure</SelectItem>
<SelectItem value="spectator">Spectator</SelectItem>
</SelectContent>
</Select>
</Field>
<Field id="difficulty" label="Difficulty">
<Select
value={formData.difficulty}
onValueChange={(value) => updateField("difficulty", toDifficulty(value))}
>
<SelectTrigger id="difficulty"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="peaceful">Peaceful</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field id="maxPlayers" label="Max Players">
<Input
id="maxPlayers"
type="number"
value={formData.maxPlayers}
onChange={(event) => updateField("maxPlayers", event.target.value)}
min="1"
max="1000"
/>
</Field>
<Field id="viewDistance" label="View Distance (chunks)">
<Input
id="viewDistance"
type="number"
value={formData.viewDistance}
onChange={(event) => updateField("viewDistance", event.target.value)}
min="3"
max="32"
/>
</Field>
</div>
<div className="space-y-3">
<CheckboxField id="pvp" checked={formData.pvp} onCheckedChange={(value) => updateField("pvp", value)}>
Enable PvP (Player vs Player)
</CheckboxField>
<CheckboxField id="onlineMode" checked={formData.onlineMode} onCheckedChange={(value) => updateField("onlineMode", value)}>
Online Mode (Requires authenticated Minecraft accounts)
</CheckboxField>
<CheckboxField id="allowFlight" checked={formData.allowFlight} onCheckedChange={(value) => updateField("allowFlight", value)}>
Allow Flight
</CheckboxField>
<CheckboxField id="enableCommandBlock" checked={formData.enableCommandBlock} onCheckedChange={(value) => updateField("enableCommandBlock", value)}>
Enable Command Blocks
</CheckboxField>
<CheckboxField id="hardcore" checked={formData.hardcore} onCheckedChange={(value) => updateField("hardcore", value)}>
Hardcore Mode (Permanent Death)
</CheckboxField>
</div>
</TabsContent>
);
}
+101
View File
@@ -0,0 +1,101 @@
import type { GameMode, ServiceType as PrismaServiceType, ServerDifficulty } from "@minikura/db";
export type ServerType = "VANILLA" | "PAPER" | "SPIGOT" | "PURPUR" | "FABRIC" | "CUSTOM";
export type ServiceType = PrismaServiceType;
export type Difficulty = ServerDifficulty;
export type Mode = GameMode;
export interface EnvVar {
id?: string;
key: string;
value: string;
}
export interface ServerFormData {
id: string;
description: string;
memoryLimit: string;
memoryRequest: string;
cpuRequest: string;
cpuLimit: string;
type: ServerType;
version?: string;
customJarUrl?: string;
eula: boolean;
listenPort: string;
serviceType: ServiceType;
nodePort?: string;
motd?: string;
difficulty: "peaceful" | "easy" | "normal" | "hard";
mode: "survival" | "creative" | "adventure" | "spectator";
maxPlayers: string;
pvp: boolean;
onlineMode: boolean;
allowFlight: boolean;
enableCommandBlock: boolean;
spawnProtection: string;
viewDistance: string;
simulationDistance: string;
levelName: string;
levelSeed?: string;
levelType?: string;
generatorSettings?: string;
hardcore: boolean;
spawnAnimals: boolean;
spawnMonsters: boolean;
spawnNpcs: boolean;
enableWhitelist: boolean;
whitelist?: string;
whitelistFile?: string;
ops?: string;
opsFile?: string;
useAikarFlags: boolean;
useMeowiceFlags: boolean;
jvmOpts?: string;
jvmXxOpts?: string;
jvmDdOpts?: string;
resourcePack?: string;
resourcePackSha1?: string;
resourcePackEnforce: boolean;
enableRcon: boolean;
rconPassword?: string;
rconPort: string;
rconCmdsStartup?: string;
rconCmdsOnConnect?: string;
rconCmdsFirstConnect?: string;
rconCmdsOnDisconnect?: string;
rconCmdsLastDisconnect?: string;
enableQuery: boolean;
queryPort: string;
enableAutopause: boolean;
autopauseTimeoutEst: string;
autopauseTimeoutInit: string;
autopauseTimeoutKn: string;
autopausePeriod: string;
autopauseKnockInterface: string;
enableAutostop: boolean;
autostopTimeoutEst: string;
autostopTimeoutInit: string;
autostopPeriod: string;
plugins?: string;
removeOldPlugins: boolean;
spigetResources?: string;
paperBuild?: string;
timezone: string;
uid: string;
gid: string;
enableJmx: boolean;
stopDuration: string;
serverIcon?: string;
envVars: EnvVar[];
}
export type UpdateServerField = <K extends keyof ServerFormData>(
key: K,
value: ServerFormData[K],
) => void;
export interface ServerFormPanelProps {
formData: ServerFormData;
updateField: UpdateServerField;
}
@@ -0,0 +1,113 @@
import { FormNotice } from "@/components/form-controls";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CheckboxField, Field } from "./fields";
import type { ServerFormPanelProps } from "./types";
export function WorldPanel({ formData, updateField }: ServerFormPanelProps) {
return (
<TabsContent value="world" className="space-y-4 mt-4">
{formData.type === "CUSTOM" && (
<FormNotice>
World options apply to standard server properties and may be ignored by custom jars.
</FormNotice>
)}
<Field id="levelName" label="World Name">
<Input
id="levelName"
value={formData.levelName}
onChange={(event) => updateField("levelName", event.target.value)}
placeholder="world"
/>
</Field>
<Field
id="levelSeed"
label="World Seed"
tooltip="Leave empty for random generation. Can be numeric or text."
>
<Input
id="levelSeed"
value={formData.levelSeed || ""}
onChange={(event) => updateField("levelSeed", event.target.value)}
placeholder="Leave empty for random"
/>
</Field>
<Field id="levelType" label="Level Type">
<Select
value={formData.levelType || "default"}
onValueChange={(value) => updateField("levelType", value === "default" ? "" : value)}
>
<SelectTrigger id="levelType"><SelectValue placeholder="Default" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="flat">Flat/Superflat</SelectItem>
<SelectItem value="largeBiomes">Large Biomes</SelectItem>
<SelectItem value="amplified">Amplified</SelectItem>
<SelectItem value="buffet">Buffet (Single Biome)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field
id="generatorSettings"
label="Generator Settings"
tooltip="JSON configuration for custom world generation. Advanced users only."
>
<Textarea
id="generatorSettings"
value={formData.generatorSettings || ""}
onChange={(event) => updateField("generatorSettings", event.target.value)}
placeholder={'{"structures": {...}, "layers": [...]}'}
rows={3}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
id="spawnProtection"
label="Spawn Protection (blocks)"
tooltip="Radius around spawn where only ops can build. Set to 0 to disable."
>
<Input
id="spawnProtection"
type="number"
value={formData.spawnProtection}
onChange={(event) => updateField("spawnProtection", event.target.value)}
min="0"
/>
</Field>
<Field
id="simulationDistance"
label="Simulation Distance"
tooltip="Distance in chunks where game logic runs (redstone, crops, etc.)"
>
<Input
id="simulationDistance"
type="number"
value={formData.simulationDistance}
onChange={(event) => updateField("simulationDistance", event.target.value)}
min="3"
max="32"
/>
</Field>
</div>
<div className="space-y-3">
<CheckboxField id="spawnAnimals" checked={formData.spawnAnimals} onCheckedChange={(value) => updateField("spawnAnimals", value)}>
Spawn Animals
</CheckboxField>
<CheckboxField id="spawnMonsters" checked={formData.spawnMonsters} onCheckedChange={(value) => updateField("spawnMonsters", value)}>
Spawn Monsters
</CheckboxField>
<CheckboxField id="spawnNpcs" checked={formData.spawnNpcs} onCheckedChange={(value) => updateField("spawnNpcs", value)}>
Spawn NPCs (Villagers)
</CheckboxField>
</div>
</TabsContent>
);
}
@@ -0,0 +1,97 @@
"use client";
import type { ConnectionInfo } from "@minikura/api";
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api-client";
import { getReverseProxyApi } from "@/lib/api-helpers";
type ConnectionInfoCellProps = {
serverId: string;
type: "normal" | "proxy";
};
export function ConnectionInfoCell({ serverId, type }: ConnectionInfoCellProps) {
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
const fetchConnectionInfo = async () => {
try {
const reverseProxyApi = getReverseProxyApi();
const endpoint =
type === "normal"
? api.api.servers({ id: serverId })["connection-info"]
: reverseProxyApi({ id: serverId })["connection-info"];
const res = await endpoint.get();
if (res.data) {
setConnectionInfo(res.data as ConnectionInfo);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchConnectionInfo();
}, [serverId, type]);
const handleCopy = async () => {
if (connectionInfo?.connectionString) {
await navigator.clipboard.writeText(connectionInfo.connectionString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (loading) {
return <span className="text-muted-foreground text-xs">Loading...</span>;
}
if (!connectionInfo) {
return <span className="text-muted-foreground text-xs">N/A</span>;
}
return (
<TooltipProvider>
<div className="flex flex-col gap-1">
<Badge variant="secondary" className="w-fit text-xs">
{connectionInfo.type}
</Badge>
{connectionInfo.connectionString && (
<div className="flex items-center gap-1">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{connectionInfo.connectionString}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={handleCopy}
aria-label="Copy connection string"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
</TooltipContent>
</Tooltip>
</div>
)}
{connectionInfo.note && (
<p className="text-xs text-muted-foreground">{connectionInfo.note}</p>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,52 @@
"use client";
import type { PodInfo } from "@minikura/api";
import { useEffect, useState } from "react";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api-client";
type ServerStatusCellProps = {
serverId: string;
type: "normal" | "proxy";
};
export function ServerStatusCell({ serverId, type }: ServerStatusCellProps) {
const [pods, setPods] = useState<PodInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPods = async () => {
try {
const endpoint =
type === "normal"
? api.api.k8s.servers({ serverId }).pods.get
: api.api.k8s["reverse-proxy"]({ serverId }).pods.get;
const res = await endpoint();
if (res.data) {
setPods(res.data as PodInfo[]);
}
} catch (_error) {
} finally {
setLoading(false);
}
};
fetchPods();
}, [serverId, type]);
if (loading) {
return <StatusBadge pulse>Checking</StatusBadge>;
}
if (pods.length === 0) {
return <StatusBadge>No pods</StatusBadge>;
}
const allRunning = pods.every((pod) => pod.status === "Running");
const readyCount = pods.filter((pod) => pod.ready === "1/1").length;
return (
<StatusBadge tone={allRunning ? "success" : "warning"}>
{readyCount}/{pods.length} ready
</StatusBadge>
);
}
@@ -0,0 +1,155 @@
import type { NormalServer, ReverseProxyServer } from "@minikura/api";
import { Pencil, Trash2 } from "lucide-react";
import { DataTable, type DataTableColumn } from "@/components/data-table";
import { TableActions } from "@/components/section-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConnectionInfoCell } from "./connection-info-cell";
import { ServerStatusCell } from "./server-status-cell";
type ServerTableProps =
| {
type: "normal";
servers: NormalServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
| {
type: "proxy";
servers: ReverseProxyServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
};
function RowActions({
id,
kind,
onEdit,
onDelete,
}: {
id: string;
kind: string;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<TableActions>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
aria-label={`Edit ${kind} ${id}`}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
aria-label={`Delete ${kind} ${id}`}
>
<Trash2 />
</Button>
</TableActions>
);
}
export function ServerTable(props: ServerTableProps) {
if (props.type === "normal") {
const columns: readonly DataTableColumn<NormalServer>[] = [
{ id: "id", header: "ID", cell: (server) => server.id, className: "font-bold" },
{
id: "status",
header: "Status",
cell: (server) => <ServerStatusCell serverId={server.id} type="normal" />,
},
{
id: "storage",
header: "Storage",
cell: (server) => <Badge variant="outline">{server.type}</Badge>,
},
{
id: "software",
header: "Software",
cell: (server) => <Badge variant="secondary">{server.jar_type || "VANILLA"}</Badge>,
},
{
id: "version",
header: "Version",
cell: (server) => server.minecraft_version || "LATEST",
className: "text-muted-foreground",
},
{ id: "memory", header: "Memory", cell: (server) => `${server.memory || 1024} MB` },
{
id: "network",
header: "Network",
cell: (server) => <ConnectionInfoCell serverId={server.id} type="normal" />,
},
{
id: "description",
header: "Description",
cell: (server) => server.description || "-",
className: "max-w-64 truncate text-muted-foreground",
},
{
id: "actions",
header: "Actions",
headerClassName: "text-right",
className: "text-right",
cell: (server) => (
<RowActions
id={server.id}
kind="server"
onEdit={props.onEdit}
onDelete={props.onDelete}
/>
),
},
];
return <DataTable data={props.servers} columns={columns} getRowKey={(server) => server.id} />;
}
const columns: readonly DataTableColumn<ReverseProxyServer>[] = [
{ id: "id", header: "ID", cell: (server) => server.id, className: "font-bold" },
{
id: "status",
header: "Status",
cell: (server) => <ServerStatusCell serverId={server.id} type="proxy" />,
},
{
id: "type",
header: "Type",
cell: (server) => <Badge variant="outline">{server.type}</Badge>,
},
{
id: "external",
header: "External",
cell: (server) => `${server.external_address}:${server.external_port}`,
},
{ id: "port", header: "Listen port", cell: (server) => server.listen_port },
{ id: "memory", header: "Memory", cell: (server) => `${server.memory} MB` },
{
id: "network",
header: "Network",
cell: (server) => <ConnectionInfoCell serverId={server.id} type="proxy" />,
},
{
id: "description",
header: "Description",
cell: (server) => server.description || "-",
className: "max-w-64 truncate text-muted-foreground",
},
{
id: "actions",
header: "Actions",
headerClassName: "text-right",
className: "text-right",
cell: (server) => (
<RowActions id={server.id} kind="proxy" onEdit={props.onEdit} onDelete={props.onDelete} />
),
},
];
return <DataTable data={props.servers} columns={columns} getRowKey={(server) => server.id} />;
}