Phase2
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:01:25 +02:00
parent 58961000eb
commit c4077d4673
93 changed files with 4099 additions and 165 deletions

View File

@@ -14,7 +14,8 @@
"submit": "Absenden",
"cancel": "Abbrechen",
"back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform."
"footer": "Selbstgehostete Minecraft-Server-Plattform.",
"servers": "Server"
},
"landing": {
"badge": "Phase 0 · Control Plane",
@@ -95,12 +96,20 @@
"welcome": "Willkommen in Ihrem Kontrollbereich.",
"servers": "Server",
"serversEmpty": "Noch keine Server",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, um loszulegen.",
"createServer": "Server erstellen",
"status": "Status",
"resources": "Ressourcen",
"activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse"
"activityEmpty": "Keine aktuellen Ereignisse",
"controlPlaneOnline": "Control Plane · Online",
"activeServers": "Aktive Server",
"resourcesSubtitle": "Zugewiesen / Kontingent",
"allocatedRam": "Zugewiesener RAM",
"activitySubtitle": "Übersicht",
"totalServers": "Server gesamt",
"viewAllServers": "Alle Server anzeigen",
"serversSummary": "Server in Ihrem Konto"
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
@@ -111,6 +120,66 @@
"passwordMismatch": "Passwörter stimmen nicht überein",
"usernameInvalid": "Benutzername: 332 Zeichen (Buchstaben, Zahlen, _ und -)",
"codeRequired": "Authentifizierungscode ist erforderlich",
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein",
"serverNameRequired": "Servername ist erforderlich",
"serverNameMax": "Servername darf höchstens 64 Zeichen lang sein",
"planRequired": "Bitte wählen Sie einen Tarif",
"planInvalid": "Ungültiger Tarif ausgewählt",
"versionRequired": "Minecraft-Version ist erforderlich",
"softwareRequired": "Software-Familie ist erforderlich",
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
},
"servers": {
"title": "Server",
"subtitle": "Verwalten Sie Ihre Minecraft-Server.",
"create": "Server erstellen",
"createTitle": "Neuer Server",
"createDescription": "Konfigurieren und provisionieren Sie einen neuen Minecraft-Server.",
"name": "Servername",
"plan": "Tarif",
"selectPlan": "Tarif auswählen",
"noPlans": "Keine Tarife verfügbar. Bitten Sie einen Administrator, einen Tarif anzulegen.",
"software": "Software",
"version": "Minecraft-Version",
"eula": "Ich akzeptiere die Minecraft End User License Agreement (EULA).",
"loading": "Wird geladen…",
"cancel": "Abbrechen",
"emptyTitle": "Noch keine Server",
"emptyDescription": "Erstellen Sie Ihren ersten Server, um zu spielen.",
"address": "Adresse",
"addressPending": "Zuweisung ausstehend",
"ram": "RAM",
"start": "Starten",
"stop": "Stoppen",
"details": "Details",
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
"backToList": "Zurück zur Serverliste",
"retry": "Erneut versuchen",
"status": {
"DRAFT": "Entwurf",
"PROVISIONING": "Provisionierung",
"INSTALLING": "Installation",
"STOPPED": "Gestoppt",
"STARTING": "Startet",
"RUNNING": "Läuft",
"STOPPING": "Stoppt",
"ERROR": "Fehler",
"DELETING": "Wird gelöscht",
"DELETED": "Gelöscht"
},
"softwareOptions": {
"VANILLA": "Vanilla",
"PAPER": "Paper",
"PURPUR": "Purpur",
"FABRIC": "Fabric",
"FORGE": "Forge",
"NEOFORGE": "NeoForge",
"QUILT": "Quilt",
"BEDROCK_DEDICATED": "Bedrock Dedicated"
},
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"loadFailed": "Server konnten nicht geladen werden."
}
}
}

View File

@@ -14,7 +14,8 @@
"submit": "Submit",
"cancel": "Cancel",
"back": "Back",
"footer": "Self-hosted Minecraft server platform."
"footer": "Self-hosted Minecraft server platform.",
"servers": "Servers"
},
"landing": {
"badge": "Phase 0 · Control Plane",
@@ -95,12 +96,20 @@
"welcome": "Welcome to your control area.",
"servers": "Servers",
"serversEmpty": "No servers yet",
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
"serversEmptyDescription": "Create your first Minecraft server to get started.",
"createServer": "Create server",
"status": "Status",
"resources": "Resources",
"activity": "Activity",
"activityEmpty": "No recent events"
"activityEmpty": "No recent events",
"controlPlaneOnline": "Control Plane · Online",
"activeServers": "Active servers",
"resourcesSubtitle": "Allocated / Quota",
"allocatedRam": "Allocated RAM",
"activitySubtitle": "Overview",
"totalServers": "Total servers",
"viewAllServers": "View all servers",
"serversSummary": "Servers in your account"
},
"validation": {
"emailRequired": "Email is required",
@@ -111,6 +120,66 @@
"passwordMismatch": "Passwords do not match",
"usernameInvalid": "Username must be 332 characters (letters, numbers, _ and -)",
"codeRequired": "Authentication code is required",
"codeInvalid": "Enter a valid 6-digit code"
"codeInvalid": "Enter a valid 6-digit code",
"serverNameRequired": "Server name is required",
"serverNameMax": "Server name must be at most 64 characters",
"planRequired": "Please select a plan",
"planInvalid": "Invalid plan selected",
"versionRequired": "Minecraft version is required",
"softwareRequired": "Software family is required",
"eulaRequired": "You must accept the Minecraft EULA"
},
"servers": {
"title": "Servers",
"subtitle": "Manage your Minecraft servers.",
"create": "Create server",
"createTitle": "New server",
"createDescription": "Configure and provision a new Minecraft server.",
"name": "Server name",
"plan": "Plan",
"selectPlan": "Select a plan",
"noPlans": "No plans are available. Ask an administrator to create a plan.",
"software": "Software",
"version": "Minecraft version",
"eula": "I accept the Minecraft End User License Agreement (EULA).",
"loading": "Loading…",
"cancel": "Cancel",
"emptyTitle": "No servers yet",
"emptyDescription": "Create your first server to start playing.",
"address": "Address",
"addressPending": "Pending allocation",
"ram": "RAM",
"start": "Start",
"stop": "Stop",
"details": "Details",
"detailSubtitle": "Server details and lifecycle controls",
"backToList": "Back to servers",
"retry": "Retry",
"status": {
"DRAFT": "Draft",
"PROVISIONING": "Provisioning",
"INSTALLING": "Installing",
"STOPPED": "Stopped",
"STARTING": "Starting",
"RUNNING": "Running",
"STOPPING": "Stopping",
"ERROR": "Error",
"DELETING": "Deleting",
"DELETED": "Deleted"
},
"softwareOptions": {
"VANILLA": "Vanilla",
"PAPER": "Paper",
"PURPUR": "Purpur",
"FABRIC": "Fabric",
"FORGE": "Forge",
"NEOFORGE": "NeoForge",
"QUILT": "Quilt",
"BEDROCK_DEDICATED": "Bedrock Dedicated"
},
"errors": {
"generic": "Something went wrong. Please try again.",
"loadFailed": "Could not load servers."
}
}
}

View File

@@ -30,6 +30,14 @@ export default async function DashboardLayout({
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<DashboardUserInfo />

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerDetailContent } from "@/components/servers/server-detail-content";
interface ServerDetailPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerDetailPage({ params }: ServerDetailPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerDetailContent serverId={id} />;
}

View File

@@ -0,0 +1,45 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface ServersLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("common");
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="App navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
{t("dashboard")}
</Link>
</li>
<li>
<Link
href="/servers"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("servers")}
</Link>
</li>
</ul>
</nav>
<DashboardUserInfo />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { CreateServerForm } from "@/components/servers/create-server-form";
interface NewServerPageProps {
params: Promise<{ locale: string }>;
}
export default async function NewServerPage({ params }: NewServerPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <CreateServerForm />;
}

View File

@@ -0,0 +1,34 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { ServerList } from "@/components/servers/server-list";
interface ServersPageProps {
params: Promise<{ locale: string }>;
}
export default async function ServersPage({ params }: ServersPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("servers");
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
</div>
<Link
href="/servers/new"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("create")}
</Link>
</div>
<ServerList />
</div>
);
}

View File

@@ -1,19 +1,25 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { Link, useRouter } from "@/i18n/navigation";
import { EmptyState } from "@/components/dashboard/empty-state";
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
import { listServers } from "@/lib/api/servers";
export function DashboardContent() {
const t = useTranslations("dashboard");
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const { data, isLoading } = useQuery({
queryKey: ["servers"],
queryFn: listServers,
});
useEffect(() => {
const timer = window.setTimeout(() => setIsLoading(false), 600);
return () => window.clearTimeout(timer);
}, []);
const servers = data?.servers ?? [];
const activeCount = servers.filter((server) =>
["RUNNING", "STARTING", "STOPPING"].includes(server.status),
).length;
if (isLoading) {
return <DashboardSkeleton />;
@@ -32,49 +38,83 @@ export function DashboardContent() {
<Card>
<CardHeader>
<CardTitle>{t("status")}</CardTitle>
<CardDescription>Control Plane · Online</CardDescription>
<CardDescription>{t("controlPlaneOnline")}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
0
{activeCount}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activeServers")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("resources")}</CardTitle>
<CardDescription>Allocated / Quota</CardDescription>
<CardDescription>{t("resourcesSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
0 / 0
{servers.reduce((sum, server) => sum + server.ramMb, 0)} MB
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("allocatedRam")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>Last 24 hours</CardDescription>
<CardDescription>{t("activitySubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{servers.length}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
</CardContent>
</Card>
</div>
<section aria-labelledby="servers-heading">
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t("servers")}
</h2>
<EmptyState
title={t("serversEmpty")}
description={t("serversEmptyDescription")}
actionLabel={t("createServer")}
/>
<div className="mb-4 flex items-center justify-between gap-4">
<h2 id="servers-heading" className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
{t("servers")}
</h2>
<Link
href="/servers"
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("viewAllServers")}
</Link>
</div>
{servers.length === 0 ? (
<EmptyState
title={t("serversEmpty")}
description={t("serversEmptyDescription")}
actionLabel={t("createServer")}
onAction={() => {
router.push("/servers/new");
}}
/>
) : (
<Card>
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("serversSummary")}</p>
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{servers.length}
</p>
</div>
<Link
href="/servers/new"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("createServer")}
</Link>
</CardContent>
</Card>
)}
</section>
</div>
);

View File

@@ -0,0 +1,212 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import { createServer, listPlans } from "@/lib/api/servers";
import {
createServerSchema,
MINECRAFT_VERSIONS,
SOFTWARE_FAMILIES,
type CreateServerFormValues,
} from "@/lib/schemas/server";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function CreateServerForm() {
const t = useTranslations("servers");
const tv = useTranslations("validation");
const router = useRouter();
const [apiError, setApiError] = useState<string | null>(null);
const { data: plansData, isLoading: plansLoading } = useQuery({
queryKey: ["plans"],
queryFn: listPlans,
retry: false,
});
const schema = createServerSchema({
nameRequired: tv("serverNameRequired"),
nameMax: tv("serverNameMax"),
planRequired: tv("planRequired"),
planInvalid: tv("planInvalid"),
versionRequired: tv("versionRequired"),
softwareRequired: tv("softwareRequired"),
eulaRequired: tv("eulaRequired"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateServerFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
planId: "",
minecraftVersion: MINECRAFT_VERSIONS[0],
softwareFamily: "VANILLA",
eulaAccepted: false,
},
});
const createMutation = useMutation({
mutationFn: createServer,
onSuccess: (server) => {
router.push(`/servers/${server.id}`);
},
onError: (error: unknown) => {
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
async function onSubmit(values: CreateServerFormValues) {
setApiError(null);
await createMutation.mutateAsync({
...values,
eulaAccepted: true,
});
}
const plans = plansData?.plans ?? [];
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{t("createTitle")}</CardTitle>
<CardDescription>{t("createDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("name")}
</label>
<input
id="name"
type="text"
className={inputClassName}
aria-invalid={errors.name ? "true" : "false"}
{...register("name")}
/>
{errors.name ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("plan")}
</label>
<select
id="planId"
className={inputClassName}
disabled={plansLoading}
aria-invalid={errors.planId ? "true" : "false"}
{...register("planId")}
>
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name} ({plan.maxRamMb} MB)
</option>
))}
</select>
{errors.planId ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
) : null}
{!plansLoading && plans.length === 0 ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="softwareFamily"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("software")}
</label>
<select
id="softwareFamily"
className={inputClassName}
aria-invalid={errors.softwareFamily ? "true" : "false"}
{...register("softwareFamily")}
>
{SOFTWARE_FAMILIES.map((family) => (
<option key={family} value={family}>
{t(`softwareOptions.${family}`)}
</option>
))}
</select>
{errors.softwareFamily ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.softwareFamily.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="minecraftVersion"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("version")}
</label>
<select
id="minecraftVersion"
className={inputClassName}
aria-invalid={errors.minecraftVersion ? "true" : "false"}
{...register("minecraftVersion")}
>
{MINECRAFT_VERSIONS.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
{errors.minecraftVersion ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.minecraftVersion.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
<span>{t("eula")}</span>
</label>
{errors.eulaAccepted ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.eulaAccepted.message}</p>
) : null}
</div>
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
{t("create")}
</Button>
<Link
href="/servers"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("cancel")}
</Link>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Link } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import {
type Server,
startServer,
stopServer,
} from "@/lib/api/servers";
import { ServerStatusBadge } from "./server-status-badge";
interface ServerCardProps {
server: Server;
showDetailsLink?: boolean;
}
export function ServerCard({ server, showDetailsLink = true }: ServerCardProps) {
const t = useTranslations("servers");
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const startMutation = useMutation({
mutationFn: () => startServer(server.id),
onSuccess: () => {
setActionError(null);
void queryClient.invalidateQueries({ queryKey: ["servers"] });
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
},
onError: (error: unknown) => {
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
const stopMutation = useMutation({
mutationFn: () => stopServer(server.id),
onSuccess: () => {
setActionError(null);
void queryClient.invalidateQueries({ queryKey: ["servers"] });
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
},
onError: (error: unknown) => {
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
const canStart = server.status === "STOPPED";
const canStop = server.status === "RUNNING";
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
const address =
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<div className="min-w-0 space-y-1">
<CardTitle className="truncate text-lg">{server.name}</CardTitle>
<CardDescription>
{server.softwareFamily} · {server.minecraftVersion}
</CardDescription>
</div>
<ServerStatusBadge status={server.status} label={t(`status.${server.status}`)} />
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-zinc-500 dark:text-zinc-400">{t("address")}</dt>
<dd className="font-mono text-zinc-900 dark:text-zinc-100">{address}</dd>
</div>
<div>
<dt className="text-zinc-500 dark:text-zinc-400">{t("ram")}</dt>
<dd className="text-zinc-900 dark:text-zinc-100">{server.ramMb} MB</dd>
</div>
</dl>
{actionError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{actionError}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => startMutation.mutate()}
disabled={!canStart || isBusy || startMutation.isPending || stopMutation.isPending}
>
{t("start")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => stopMutation.mutate()}
disabled={!canStop || isBusy || startMutation.isPending || stopMutation.isPending}
>
{t("stop")}
</Button>
{showDetailsLink ? (
<Link
href={`/servers/${server.id}`}
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("details")}
</Link>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getServer } from "@/lib/api/servers";
import { ServerCard } from "@/components/servers/server-card";
interface ServerDetailContentProps {
serverId: string;
}
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
refetchInterval: (query) => {
const status = query.state.data?.status;
if (!status) {
return false;
}
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
? 3000
: false;
},
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
{t("retry")}
</Button>
<Link
href="/servers"
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("backToList")}
</Link>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{data.name}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
</div>
<Link
href="/servers"
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("backToList")}
</Link>
</div>
<ServerCard server={data} showDetailsLink={false} />
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { listServers } from "@/lib/api/servers";
import { ServerCard } from "./server-card";
export function ServerList() {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["servers"],
queryFn: listServers,
});
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
{t("retry")}
</Button>
</div>
);
}
const servers = data?.servers ?? [];
if (servers.length === 0) {
return (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">{t("emptyTitle")}</h2>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">{t("emptyDescription")}</p>
<Link
href="/servers/new"
className="mt-4 inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("create")}
</Link>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
{servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { cn } from "@hexahost/ui";
import type { GameServerStatus } from "@/lib/api/servers";
const statusStyles: Record<GameServerStatus, string> = {
DRAFT: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",
};
interface ServerStatusBadgeProps {
status: GameServerStatus;
label: string;
className?: string;
}
export function ServerStatusBadge({ status, label, className }: ServerStatusBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
statusStyles[status],
className,
)}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,104 @@
import { apiFetch } from "./auth";
export type GameServerStatus =
| "DRAFT"
| "PROVISIONING"
| "INSTALLING"
| "STOPPED"
| "STARTING"
| "RUNNING"
| "STOPPING"
| "ERROR"
| "DELETING"
| "DELETED";
export type SoftwareFamily =
| "VANILLA"
| "PAPER"
| "PURPUR"
| "FABRIC"
| "FORGE"
| "NEOFORGE"
| "QUILT"
| "BEDROCK_DEDICATED";
export interface Server {
id: string;
userId: string;
planId: string | null;
nodeId: string | null;
name: string;
status: GameServerStatus;
version: number;
edition: "JAVA" | "BEDROCK";
softwareFamily: SoftwareFamily;
minecraftVersion: string;
eulaAccepted: boolean;
hostPort: number | null;
containerId: string | null;
dataPath: string | null;
ramMb: number;
createdAt: string;
updatedAt: string;
}
export interface ServerListResponse {
servers: Server[];
}
export interface ServerActionResponse {
server: Server;
jobId?: string;
}
export interface CreateServerPayload {
name: string;
planId?: string;
minecraftVersion: string;
softwareFamily: SoftwareFamily;
eulaAccepted: true;
}
export interface Plan {
id: string;
name: string;
slug: string;
description: string | null;
isFree: boolean;
maxRamMb: number;
}
export interface PlanListResponse {
plans: Plan[];
}
export async function listServers(): Promise<ServerListResponse> {
return apiFetch<ServerListResponse>("/servers");
}
export async function getServer(id: string): Promise<Server> {
return apiFetch<Server>(`/servers/${id}`);
}
export async function createServer(payload: CreateServerPayload): Promise<Server> {
return apiFetch<Server>("/servers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function startServer(id: string): Promise<ServerActionResponse> {
return apiFetch<ServerActionResponse>(`/servers/${id}/start`, {
method: "POST",
});
}
export async function stopServer(id: string): Promise<ServerActionResponse> {
return apiFetch<ServerActionResponse>(`/servers/${id}/stop`, {
method: "POST",
});
}
export async function listPlans(): Promise<PlanListResponse> {
return apiFetch<PlanListResponse>("/plans");
}

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
const softwareFamilyValues = [
"VANILLA",
"PAPER",
"PURPUR",
"FABRIC",
"FORGE",
"NEOFORGE",
"QUILT",
"BEDROCK_DEDICATED",
] as const;
export function createServerSchema(messages: {
nameRequired: string;
nameMax: string;
planRequired: string;
planInvalid: string;
versionRequired: string;
softwareRequired: string;
eulaRequired: string;
}) {
return z.object({
name: z
.string()
.min(1, messages.nameRequired)
.max(64, messages.nameMax),
planId: z
.string()
.min(1, messages.planRequired)
.uuid(messages.planInvalid),
minecraftVersion: z.string().min(1, messages.versionRequired),
softwareFamily: z.enum(softwareFamilyValues, {
errorMap: () => ({ message: messages.softwareRequired }),
}),
eulaAccepted: z
.boolean()
.refine((value) => value === true, { message: messages.eulaRequired }),
});
}
export type CreateServerFormValues = z.infer<ReturnType<typeof createServerSchema>>;
export const MINECRAFT_VERSIONS = ["1.21.1", "1.21", "1.20.6", "1.20.4"] as const;
export const SOFTWARE_FAMILIES = softwareFamilyValues;

View File

@@ -4,7 +4,7 @@ import { routing } from "./i18n/routing";
const intlMiddleware = createMiddleware(routing);
const protectedPaths = ["/dashboard", "/security"];
const protectedPaths = ["/dashboard", "/security", "/servers"];
function getPathWithoutLocale(pathname: string): string {
if (pathname === "/en" || pathname.startsWith("/en/")) {

File diff suppressed because one or more lines are too long