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

This commit is contained in:
smueller
2026-06-26 12:17:26 +02:00
parent c4077d4673
commit 9b061c3ee7
92 changed files with 4128 additions and 146 deletions

View File

@@ -127,7 +127,10 @@
"planInvalid": "Ungültiger Tarif ausgewählt",
"versionRequired": "Minecraft-Version ist erforderlich",
"softwareRequired": "Software-Familie ist erforderlich",
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren",
"motdMax": "MOTD darf höchstens 256 Zeichen lang sein",
"maxPlayersMin": "Mindestens 1 Spieler erforderlich",
"maxPlayersMax": "Höchstens 200 Spieler erlaubt"
},
"servers": {
"title": "Server",
@@ -180,6 +183,97 @@
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"loadFailed": "Server konnten nicht geladen werden."
},
"tabs": {
"navigation": "Server-Bereiche",
"overview": "Übersicht",
"console": "Konsole",
"files": "Dateien",
"properties": "Eigenschaften",
"players": "Spieler"
},
"console": {
"loading": "Wird geladen…",
"notRunning": "Die Konsole ist nur verfügbar, wenn der Server läuft. Starten Sie den Server über die Übersicht.",
"connecting": "Verbindung wird hergestellt…",
"connected": "Verbunden",
"disconnected": "Getrennt",
"clear": "Log leeren",
"commandPlaceholder": "Serverbefehl eingeben…",
"send": "Senden",
"errors": {
"connectionFailed": "Verbindung zur Konsole fehlgeschlagen."
}
},
"files": {
"loading": "Wird geladen…",
"browser": "Dateibrowser",
"editor": "Editor",
"up": "Hoch",
"empty": "Dieses Verzeichnis ist leer.",
"selectFile": "Wählen Sie eine Datei zum Anzeigen oder Bearbeiten.",
"readOnlyStopped": "Schreibgeschützt, solange der Server gestoppt ist.",
"notAvailable": "Dateien sind nur verfügbar, wenn der Server läuft oder gestoppt ist.",
"truncated": "Datei wurde zur Anzeige gekürzt. Bearbeiten Sie mit Vorsicht.",
"save": "Speichern",
"saving": "Wird gespeichert…",
"saved": "Datei gespeichert.",
"errors": {
"loadFailed": "Verzeichnis konnte nicht geladen werden.",
"readFailed": "Datei konnte nicht gelesen werden.",
"saveFailed": "Datei konnte nicht gespeichert werden."
}
},
"properties": {
"title": "Server-Eigenschaften",
"description": "Bearbeiten Sie gängige server.properties-Einstellungen. Unbekannte Eigenschaften bleiben auf dem Server erhalten.",
"loading": "Wird geladen…",
"motd": "MOTD",
"gamemode": "Spielmodus",
"difficulty": "Schwierigkeit",
"maxPlayers": "Max. Spieler",
"pvp": "PVP",
"whitelist": "Whitelist",
"hardcore": "Hardcore",
"onlineMode": "Online-Modus",
"enableCommandBlock": "Befehlsblöcke",
"allowFlight": "Fliegen erlauben",
"viewDistance": "Sichtweite",
"simulationDistance": "Simulationsdistanz",
"spawnProtection": "Spawn-Schutz",
"save": "Änderungen speichern",
"saving": "Wird gespeichert…",
"saved": "Eigenschaften gespeichert.",
"gamemodes": {
"survival": "Überleben",
"creative": "Kreativ",
"adventure": "Abenteuer",
"spectator": "Zuschauer"
},
"difficulties": {
"peaceful": "Friedlich",
"easy": "Einfach",
"normal": "Normal",
"hard": "Schwer"
},
"errors": {
"loadFailed": "Server-Eigenschaften konnten nicht geladen werden.",
"saveFailed": "Server-Eigenschaften konnten nicht gespeichert werden."
}
},
"players": {
"loading": "Wird geladen…",
"onlineCount": "Spieler online",
"whitelistCount": "Whitelist-Einträge",
"empty": "Keine Spieler online.",
"retry": "Erneut versuchen",
"columns": {
"name": "Spieler",
"ping": "Ping"
},
"errors": {
"loadFailed": "Spielerliste konnte nicht geladen werden."
}
}
}
}

View File

@@ -127,7 +127,10 @@
"planInvalid": "Invalid plan selected",
"versionRequired": "Minecraft version is required",
"softwareRequired": "Software family is required",
"eulaRequired": "You must accept the Minecraft EULA"
"eulaRequired": "You must accept the Minecraft EULA",
"motdMax": "MOTD must be at most 256 characters",
"maxPlayersMin": "At least 1 player required",
"maxPlayersMax": "At most 200 players allowed"
},
"servers": {
"title": "Servers",
@@ -180,6 +183,97 @@
"errors": {
"generic": "Something went wrong. Please try again.",
"loadFailed": "Could not load servers."
},
"tabs": {
"navigation": "Server sections",
"overview": "Overview",
"console": "Console",
"files": "Files",
"properties": "Properties",
"players": "Players"
},
"console": {
"loading": "Loading…",
"notRunning": "The console is only available while the server is running. Start the server from the overview tab.",
"connecting": "Connecting…",
"connected": "Connected",
"disconnected": "Disconnected",
"clear": "Clear log",
"commandPlaceholder": "Enter server command…",
"send": "Send",
"errors": {
"connectionFailed": "Could not connect to the console."
}
},
"files": {
"loading": "Loading…",
"browser": "File browser",
"editor": "Editor",
"up": "Up",
"empty": "This directory is empty.",
"selectFile": "Select a file to view or edit.",
"readOnlyStopped": "Read-only while the server is stopped.",
"notAvailable": "Files are only available when the server is running or stopped.",
"truncated": "File was truncated for display. Edit with care.",
"save": "Save",
"saving": "Saving…",
"saved": "File saved.",
"errors": {
"loadFailed": "Could not load directory.",
"readFailed": "Could not read file.",
"saveFailed": "Could not save file."
}
},
"properties": {
"title": "Server properties",
"description": "Edit common server.properties settings. Unknown properties are preserved on the server.",
"loading": "Loading…",
"motd": "MOTD",
"gamemode": "Gamemode",
"difficulty": "Difficulty",
"maxPlayers": "Max players",
"pvp": "PVP",
"whitelist": "Whitelist",
"hardcore": "Hardcore",
"onlineMode": "Online mode",
"enableCommandBlock": "Command blocks",
"allowFlight": "Allow flight",
"viewDistance": "View distance",
"simulationDistance": "Simulation distance",
"spawnProtection": "Spawn protection",
"save": "Save changes",
"saving": "Saving…",
"saved": "Properties saved.",
"gamemodes": {
"survival": "Survival",
"creative": "Creative",
"adventure": "Adventure",
"spectator": "Spectator"
},
"difficulties": {
"peaceful": "Peaceful",
"easy": "Easy",
"normal": "Normal",
"hard": "Hard"
},
"errors": {
"loadFailed": "Could not load server properties.",
"saveFailed": "Could not save server properties."
}
},
"players": {
"loading": "Loading…",
"onlineCount": "Online players",
"whitelistCount": "Whitelist entries",
"empty": "No players online.",
"retry": "Retry",
"columns": {
"name": "Player",
"ping": "Ping"
},
"errors": {
"loadFailed": "Could not load player list."
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import { setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { ServerDetailShell } from "@/components/servers/server-detail-shell";
interface ServerDetailLayoutProps {
children: ReactNode;
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerDetailLayout({ children, params }: ServerDetailLayoutProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerDetailShell serverId={id}>{children}</ServerDetailShell>;
}

View File

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

View File

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

View File

@@ -0,0 +1,215 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError } from "@/lib/api/auth";
import {
createConsoleWebSocket,
getConsoleToken,
sendConsoleCommand,
} from "@/lib/api/console";
import { getServer } from "@/lib/api/servers";
import { parseAnsiLine } from "@/lib/utils/ansi";
const MAX_LOG_LINES = 500;
interface LogLine {
id: number;
segments: ReturnType<typeof parseAnsiLine>;
}
interface ServerConsoleProps {
serverId: string;
}
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 font-mono 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 ServerConsole({ serverId }: ServerConsoleProps) {
const t = useTranslations("servers.console");
const [lines, setLines] = useState<LogLine[]>([]);
const [command, setCommand] = useState("");
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const lineIdRef = useRef(0);
const socketRef = useRef<WebSocket | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const appendLine = useCallback((line: string) => {
lineIdRef.current += 1;
setLines((prev) => {
const next = [
...prev,
{ id: lineIdRef.current, segments: parseAnsiLine(line) },
];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
}, []);
useEffect(() => {
if (autoScrollRef.current) {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [lines]);
useEffect(() => {
if (!server || server.status !== "RUNNING") {
return;
}
let cancelled = false;
async function connect() {
setConnecting(true);
setError(null);
try {
const { token } = await getConsoleToken(serverId);
if (cancelled) {
return;
}
const socket = createConsoleWebSocket(serverId, token, {
onOpen: () => {
if (!cancelled) {
setConnected(true);
setConnecting(false);
}
},
onClose: () => {
setConnected(false);
setConnecting(false);
},
onError: () => {
if (!cancelled) {
setError(t("errors.connectionFailed"));
setConnecting(false);
}
},
onLine: (line) => {
appendLine(line);
},
});
socketRef.current = socket;
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError ? (err.detail ?? err.title) : t("errors.connectionFailed"),
);
setConnecting(false);
}
}
}
void connect();
return () => {
cancelled = true;
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
setConnecting(false);
};
}, [server, serverId, appendLine, t]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = command.trim();
if (!trimmed || !socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
return;
}
sendConsoleCommand(socketRef.current, trimmed);
appendLine(`> ${trimmed}`);
setCommand("");
}
if (!server) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (server.status !== "RUNNING") {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<p className="text-sm text-amber-800 dark:text-amber-200">{t("notRunning")}</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{connecting
? t("connecting")
: connected
? t("connected")
: t("disconnected")}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => setLines([])}
disabled={lines.length === 0}
>
{t("clear")}
</Button>
</div>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
<div
className="h-96 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-950 p-3 font-mono text-xs leading-relaxed dark:border-zinc-800"
onScroll={(event) => {
const target = event.currentTarget;
const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 32;
autoScrollRef.current = atBottom;
}}
>
<pre className="whitespace-pre-wrap break-words text-zinc-100">
<code>
{lines.map((line) => (
<div key={line.id}>
{line.segments.map((segment, index) => (
<span key={index} className={segment.className}>
{segment.text}
</span>
))}
</div>
))}
<div ref={logEndRef} />
</code>
</pre>
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder={t("commandPlaceholder")}
className={inputClassName}
disabled={!connected}
autoComplete="off"
spellCheck={false}
/>
<Button type="submit" disabled={!connected || !command.trim()}>
{t("send")}
</Button>
</form>
</div>
);
}

View File

@@ -3,9 +3,8 @@
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";
import { getServer } from "@/lib/api/servers";
interface ServerDetailContentProps {
serverId: string;
@@ -13,18 +12,9 @@ interface ServerDetailContentProps {
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
const t = useTranslations("servers");
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError } = 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) {
@@ -32,42 +22,8 @@ export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
}
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 null;
}
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>
);
return <ServerCard server={data} showDetailsLink={false} />;
}

View File

@@ -0,0 +1,77 @@
"use client";
import { Button } from "@hexahost/ui";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { getServer } from "@/lib/api/servers";
import { ServerTabs } from "./server-tabs";
interface ServerDetailShellProps {
serverId: string;
children: ReactNode;
}
export function ServerDetailShell({ serverId, children }: ServerDetailShellProps) {
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>
<ServerTabs serverId={serverId} />
{children}
</div>
);
}

View File

@@ -0,0 +1,227 @@
"use client";
import { Button } from "@hexahost/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/api/auth";
import { listFiles, readFile, writeFile } from "@/lib/api/files";
import { getServer } from "@/lib/api/servers";
interface ServerFilesProps {
serverId: string;
}
const textareaClassName =
"min-h-80 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 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 ServerFiles({ serverId }: ServerFilesProps) {
const t = useTranslations("servers.files");
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState("");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState("");
const [fileVersion, setFileVersion] = useState<number | undefined>();
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const canAccessFiles =
server?.status === "RUNNING" || server?.status === "STOPPED";
const {
data: listing,
isLoading: listingLoading,
isError: listingError,
} = useQuery({
queryKey: ["server-files", serverId, currentPath],
queryFn: () => listFiles(serverId, currentPath),
enabled: Boolean(server && canAccessFiles),
});
const {
data: fileData,
isLoading: fileLoading,
isError: fileError,
} = useQuery({
queryKey: ["server-file", serverId, selectedFile],
queryFn: () => readFile(serverId, selectedFile!),
enabled: Boolean(selectedFile && canAccessFiles),
});
useEffect(() => {
if (fileData) {
setEditorContent(fileData.content);
setFileVersion(fileData.version);
}
}, [fileData]);
const saveMutation = useMutation({
mutationFn: () =>
writeFile(serverId, {
path: selectedFile!,
content: editorContent,
version: fileVersion,
}),
onSuccess: (result) => {
setError(null);
setSaveSuccess(true);
setFileVersion(result.version);
void queryClient.invalidateQueries({ queryKey: ["server-file", serverId, selectedFile] });
setTimeout(() => setSaveSuccess(false), 2000);
},
onError: (err: unknown) => {
setSaveSuccess(false);
setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed"));
},
});
function handleSelectEntry(path: string, isDir: boolean) {
setError(null);
setSaveSuccess(false);
if (isDir) {
setCurrentPath(path);
setSelectedFile(null);
setEditorContent("");
return;
}
setSelectedFile(path);
setEditorContent("");
setFileVersion(undefined);
}
function handleNavigateUp() {
if (!currentPath) {
return;
}
const segments = currentPath.split("/").filter(Boolean);
segments.pop();
setCurrentPath(segments.join("/"));
setSelectedFile(null);
setEditorContent("");
setError(null);
}
if (!server) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (!canAccessFiles) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<p className="text-sm text-amber-800 dark:text-amber-200">{t("notAvailable")}</p>
</div>
);
}
const isReadOnly = server.status === "STOPPED";
return (
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("browser")}</h2>
{currentPath ? (
<Button size="sm" variant="secondary" onClick={handleNavigateUp}>
{t("up")}
</Button>
) : null}
</div>
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
/{currentPath || ""}
</p>
{isReadOnly ? (
<p className="text-xs text-amber-700 dark:text-amber-300">{t("readOnlyStopped")}</p>
) : null}
{listingLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : listingError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>
) : (
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{(listing?.entries ?? []).length === 0 ? (
<li className="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">{t("empty")}</li>
) : (
(listing?.entries ?? []).map((entry) => (
<li key={entry.path}>
<button
type="button"
onClick={() => handleSelectEntry(entry.path, entry.isDir)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<span className="w-8 shrink-0 text-xs text-zinc-400">
{entry.isDir ? "DIR" : "FILE"}
</span>
<span className="truncate text-zinc-900 dark:text-zinc-100">{entry.name}</span>
{!entry.isDir ? (
<span className="ml-auto text-xs text-zinc-500">{entry.size} B</span>
) : null}
</button>
</li>
))
)}
</ul>
)}
</div>
<div className="space-y-3">
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{t("editor")}</h2>
{!selectedFile ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("selectFile")}</p>
) : fileLoading ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>
) : fileError ? (
<p className="text-sm text-red-600 dark:text-red-400">{t("errors.readFailed")}</p>
) : (
<div className="space-y-3">
<p className="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
{selectedFile}
</p>
{fileData?.truncated ? (
<p className="text-xs text-amber-700 dark:text-amber-300">{t("truncated")}</p>
) : null}
<textarea
className={textareaClassName}
value={editorContent}
onChange={(event) => {
setEditorContent(event.target.value);
setSaveSuccess(false);
}}
readOnly={isReadOnly}
spellCheck={false}
/>
{error ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
) : null}
{saveSuccess ? (
<p className="text-sm text-green-600 dark:text-green-400">{t("saved")}</p>
) : null}
{!isReadOnly ? (
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? t("saving") : t("save")}
</Button>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { getPlayers } from "@/lib/api/players";
interface ServerPlayersProps {
serverId: string;
}
export function ServerPlayers({ serverId }: ServerPlayersProps) {
const t = useTranslations("servers.players");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["server-players", serverId],
queryFn: () => getPlayers(serverId),
refetchInterval: 15000,
});
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>
<button
type="button"
onClick={() => void refetch()}
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
>
{t("retry")}
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("onlineCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.online.length}
<span className="text-base font-normal text-zinc-500"> / {data.maxPlayers}</span>
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{t("whitelistCount")}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{data.whitelistCount}
</p>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.name")}
</th>
<th className="px-4 py-3 text-left font-medium text-zinc-600 dark:text-zinc-400">
{t("columns.ping")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950">
{data.online.length === 0 ? (
<tr>
<td
colSpan={2}
className="px-4 py-6 text-center text-zinc-500 dark:text-zinc-400"
>
{t("empty")}
</td>
</tr>
) : (
data.online.map((player) => (
<tr key={player.uuid ?? player.name}>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
{player.name}
</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">
{player.ping !== undefined ? `${player.ping} ms` : "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,318 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ApiError } from "@/lib/api/auth";
import {
getProperties,
updateProperties,
type Difficulty,
type GameMode,
type ServerProperties,
} from "@/lib/api/properties";
interface ServerPropertiesFormProps {
serverId: string;
}
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";
const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
const GAME_MODES: GameMode[] = ["survival", "creative", "adventure", "spectator"];
const DIFFICULTIES: Difficulty[] = ["peaceful", "easy", "normal", "hard"];
function createPropertiesSchema(messages: {
motdMax: string;
maxPlayersMin: string;
maxPlayersMax: string;
}) {
return z.object({
motd: z.string().max(256, messages.motdMax),
gamemode: z.enum(["survival", "creative", "adventure", "spectator"]),
difficulty: z.enum(["peaceful", "easy", "normal", "hard"]),
maxPlayers: z.coerce.number().int().min(1, messages.maxPlayersMin).max(200, messages.maxPlayersMax),
pvp: z.boolean(),
whitelist: z.boolean(),
hardcore: z.boolean().optional(),
onlineMode: z.boolean().optional(),
enableCommandBlock: z.boolean().optional(),
viewDistance: z.coerce.number().int().min(3).max(32).optional(),
simulationDistance: z.coerce.number().int().min(3).max(32).optional(),
spawnProtection: z.coerce.number().int().min(0).max(64).optional(),
allowFlight: z.boolean().optional(),
});
}
type PropertiesFormValues = z.infer<ReturnType<typeof createPropertiesSchema>>;
export function ServerPropertiesForm({ serverId }: ServerPropertiesFormProps) {
const t = useTranslations("servers.properties");
const tv = useTranslations("validation");
const queryClient = useQueryClient();
const [apiError, setApiError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const schema = createPropertiesSchema({
motdMax: tv("motdMax"),
maxPlayersMin: tv("maxPlayersMin"),
maxPlayersMax: tv("maxPlayersMax"),
});
const { data, isLoading, isError } = useQuery({
queryKey: ["server-properties", serverId],
queryFn: () => getProperties(serverId),
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<PropertiesFormValues>({
resolver: zodResolver(schema),
defaultValues: {
motd: "",
gamemode: "survival",
difficulty: "normal",
maxPlayers: 20,
pvp: true,
whitelist: false,
hardcore: false,
onlineMode: true,
enableCommandBlock: false,
viewDistance: 10,
simulationDistance: 10,
spawnProtection: 16,
allowFlight: false,
},
});
useEffect(() => {
if (data) {
reset(mapPropertiesToForm(data));
}
}, [data, reset]);
const saveMutation = useMutation({
mutationFn: (values: PropertiesFormValues) => updateProperties(serverId, values),
onSuccess: (result) => {
setApiError(null);
setSaved(true);
reset(mapPropertiesToForm(result));
void queryClient.invalidateQueries({ queryKey: ["server-properties", serverId] });
setTimeout(() => setSaved(false), 2000);
},
onError: (error: unknown) => {
setSaved(false);
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.saveFailed"));
},
});
async function onSubmit(values: PropertiesFormValues) {
setApiError(null);
await saveMutation.mutateAsync(values);
}
if (isLoading) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
}
if (isError || !data) {
return <p className="text-sm text-red-600 dark:text-red-400">{t("errors.loadFailed")}</p>;
}
return (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2">
<label htmlFor="motd" className={labelClassName}>
{t("motd")}
</label>
<input id="motd" type="text" className={inputClassName} {...register("motd")} />
{errors.motd ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.motd.message}</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="gamemode" className={labelClassName}>
{t("gamemode")}
</label>
<select id="gamemode" className={inputClassName} {...register("gamemode")}>
{GAME_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`gamemodes.${mode}`)}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label htmlFor="difficulty" className={labelClassName}>
{t("difficulty")}
</label>
<select id="difficulty" className={inputClassName} {...register("difficulty")}>
{DIFFICULTIES.map((level) => (
<option key={level} value={level}>
{t(`difficulties.${level}`)}
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<label htmlFor="maxPlayers" className={labelClassName}>
{t("maxPlayers")}
</label>
<input
id="maxPlayers"
type="number"
min={1}
max={200}
className={inputClassName}
{...register("maxPlayers")}
/>
{errors.maxPlayers ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.maxPlayers.message}</p>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="h-4 w-4 rounded border-zinc-300" {...register("pvp")} />
{t("pvp")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("whitelist")}
/>
{t("whitelist")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("hardcore")}
/>
{t("hardcore")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("onlineMode")}
/>
{t("onlineMode")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("enableCommandBlock")}
/>
{t("enableCommandBlock")}
</label>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-zinc-300"
{...register("allowFlight")}
/>
{t("allowFlight")}
</label>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<label htmlFor="viewDistance" className={labelClassName}>
{t("viewDistance")}
</label>
<input
id="viewDistance"
type="number"
min={3}
max={32}
className={inputClassName}
{...register("viewDistance")}
/>
</div>
<div className="space-y-2">
<label htmlFor="simulationDistance" className={labelClassName}>
{t("simulationDistance")}
</label>
<input
id="simulationDistance"
type="number"
min={3}
max={32}
className={inputClassName}
{...register("simulationDistance")}
/>
</div>
<div className="space-y-2">
<label htmlFor="spawnProtection" className={labelClassName}>
{t("spawnProtection")}
</label>
<input
id="spawnProtection"
type="number"
min={0}
max={64}
className={inputClassName}
{...register("spawnProtection")}
/>
</div>
</div>
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
{saved ? (
<p className="text-sm text-green-600 dark:text-green-400">{t("saved")}</p>
) : null}
<Button type="submit" disabled={isSubmitting || saveMutation.isPending || !isDirty}>
{isSubmitting || saveMutation.isPending ? t("saving") : t("save")}
</Button>
</form>
</CardContent>
</Card>
);
}
function mapPropertiesToForm(properties: ServerProperties): PropertiesFormValues {
return {
motd: properties.motd,
gamemode: properties.gamemode,
difficulty: properties.difficulty,
maxPlayers: properties.maxPlayers,
pvp: properties.pvp,
whitelist: properties.whitelist,
hardcore: properties.hardcore ?? false,
onlineMode: properties.onlineMode ?? true,
enableCommandBlock: properties.enableCommandBlock ?? false,
viewDistance: properties.viewDistance ?? 10,
simulationDistance: properties.simulationDistance ?? 10,
spawnProtection: properties.spawnProtection ?? 16,
allowFlight: properties.allowFlight ?? false,
};
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
interface ServerTabsProps {
serverId: string;
}
const tabs = [
{ key: "overview", suffix: "" },
{ key: "console", suffix: "/console" },
{ key: "files", suffix: "/files" },
{ key: "properties", suffix: "/properties" },
{ key: "players", suffix: "/players" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {
const t = useTranslations("servers.tabs");
const pathname = usePathname();
const basePath = `/servers/${serverId}`;
function isActive(suffix: string): boolean {
const href = `${basePath}${suffix}`;
if (suffix === "") {
return pathname === basePath;
}
return pathname === href || pathname.startsWith(`${href}/`);
}
return (
<nav
aria-label={t("navigation")}
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
{tabs.map((tab) => {
const href = `${basePath}${tab.suffix}`;
const active = isActive(tab.suffix);
return (
<Link
key={tab.key}
href={href}
className={[
"inline-flex items-center border-b-2 px-3 py-2 text-sm font-medium transition-colors",
active
? "border-sky-500 text-sky-600 dark:text-sky-400"
: "border-transparent text-zinc-600 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-100",
].join(" ")}
aria-current={active ? "page" : undefined}
>
{t(tab.key)}
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,86 @@
import { getApiUrl } from "../env";
import { apiFetch } from "./auth";
export interface ConsoleTokenResponse {
token: string;
expiresAt: string;
}
export interface ConsoleWebSocketHandlers {
onOpen?: () => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
onLine?: (line: string, stream?: string) => void;
}
interface ConsoleLogMessage {
type?: string;
line?: string;
stream?: string;
message?: string;
}
function getConsoleWebSocketUrl(serverId: string, token: string): string {
const apiUrl = new URL(getApiUrl());
apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
const basePath = apiUrl.pathname.replace(/\/$/, "");
apiUrl.pathname = `${basePath}/servers/${serverId}/console/ws`;
apiUrl.search = new URLSearchParams({ token }).toString();
return apiUrl.toString();
}
export async function getConsoleToken(serverId: string): Promise<ConsoleTokenResponse> {
return apiFetch<ConsoleTokenResponse>(`/servers/${serverId}/console/token`);
}
export function createConsoleWebSocket(
serverId: string,
token: string,
handlers: ConsoleWebSocketHandlers,
): WebSocket {
const socket = new WebSocket(getConsoleWebSocketUrl(serverId, token));
socket.addEventListener("open", () => {
handlers.onOpen?.();
});
socket.addEventListener("close", (event) => {
handlers.onClose?.(event);
});
socket.addEventListener("error", (event) => {
handlers.onError?.(event);
});
socket.addEventListener("message", (event) => {
const raw = typeof event.data === "string" ? event.data : "";
try {
const parsed = JSON.parse(raw) as ConsoleLogMessage;
if (parsed.type === "log" && parsed.line) {
handlers.onLine?.(parsed.line, parsed.stream);
return;
}
if (parsed.line) {
handlers.onLine?.(parsed.line, parsed.stream);
return;
}
if (parsed.message) {
handlers.onLine?.(parsed.message, parsed.stream);
return;
}
} catch {
// Plain text log line
}
if (raw) {
handlers.onLine?.(raw);
}
});
return socket;
}
export function sendConsoleCommand(socket: WebSocket, command: string): void {
socket.send(JSON.stringify({ command }));
}

View File

@@ -0,0 +1,61 @@
import { apiFetch } from "./auth";
export interface FileEntry {
name: string;
path: string;
isDir: boolean;
size: number;
modifiedAt?: string;
}
export interface ListFilesResponse {
path: string;
entries: FileEntry[];
}
export interface FileContentResponse {
path: string;
content: string;
truncated?: boolean;
version?: number;
}
export interface WriteFilePayload {
path: string;
content: string;
version?: number;
}
export async function listFiles(
serverId: string,
path = "",
): Promise<ListFilesResponse> {
const params = new URLSearchParams();
if (path) {
params.set("path", path);
}
const query = params.toString();
return apiFetch<ListFilesResponse>(
`/servers/${serverId}/files${query ? `?${query}` : ""}`,
);
}
export async function readFile(
serverId: string,
path: string,
): Promise<FileContentResponse> {
const params = new URLSearchParams({ path });
return apiFetch<FileContentResponse>(
`/servers/${serverId}/files/content?${params.toString()}`,
);
}
export async function writeFile(
serverId: string,
payload: WriteFilePayload,
): Promise<FileContentResponse> {
return apiFetch<FileContentResponse>(`/servers/${serverId}/files/content`, {
method: "PUT",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,17 @@
import { apiFetch } from "./auth";
export interface OnlinePlayer {
name: string;
uuid?: string;
ping?: number;
}
export interface PlayerListResponse {
online: OnlinePlayer[];
maxPlayers: number;
whitelistCount: number;
}
export async function getPlayers(serverId: string): Promise<PlayerListResponse> {
return apiFetch<PlayerListResponse>(`/servers/${serverId}/players`);
}

View File

@@ -0,0 +1,36 @@
import { apiFetch } from "./auth";
export type GameMode = "survival" | "creative" | "adventure" | "spectator";
export type Difficulty = "peaceful" | "easy" | "normal" | "hard";
export interface ServerProperties {
motd: string;
gamemode: GameMode;
difficulty: Difficulty;
maxPlayers: number;
pvp: boolean;
whitelist: boolean;
hardcore?: boolean;
onlineMode?: boolean;
enableCommandBlock?: boolean;
viewDistance?: number;
simulationDistance?: number;
spawnProtection?: number;
allowFlight?: boolean;
}
export type UpdateServerPropertiesPayload = Partial<ServerProperties>;
export async function getProperties(serverId: string): Promise<ServerProperties> {
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`);
}
export async function updateProperties(
serverId: string,
payload: UpdateServerPropertiesPayload,
): Promise<ServerProperties> {
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,69 @@
const ANSI_PATTERN =
/\u001b\[[0-9;]*m|\u001b\]8;;.*?\u001b\\|\u001b\][0-9];.*?\u0007/g;
const ANSI_COLOR_MAP: Record<string, string> = {
"30": "text-zinc-400",
"31": "text-red-400",
"32": "text-green-400",
"33": "text-yellow-400",
"34": "text-blue-400",
"35": "text-purple-400",
"36": "text-cyan-400",
"37": "text-zinc-200",
"90": "text-zinc-500",
"91": "text-red-300",
"92": "text-green-300",
"93": "text-yellow-300",
"94": "text-blue-300",
"95": "text-purple-300",
"96": "text-cyan-300",
"97": "text-zinc-100",
};
export function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
export interface AnsiSegment {
text: string;
className?: string;
}
export function parseAnsiLine(line: string): AnsiSegment[] {
const segments: AnsiSegment[] = [];
let currentClass: string | undefined;
let buffer = "";
const regex = /\u001b\[([0-9;]*)m/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
const text = line.slice(lastIndex, match.index);
if (text) {
segments.push({ text, className: currentClass });
}
}
const codes = match[1].split(";").filter(Boolean);
if (codes.length === 0 || codes.includes("0")) {
currentClass = undefined;
} else {
const colorCode = codes.find((code) => ANSI_COLOR_MAP[code]);
currentClass = colorCode ? ANSI_COLOR_MAP[colorCode] : currentClass;
}
lastIndex = regex.lastIndex;
}
buffer = line.slice(lastIndex).replace(ANSI_PATTERN, "");
if (buffer) {
segments.push({ text: buffer, className: currentClass });
}
if (segments.length === 0) {
return [{ text: stripAnsi(line) }];
}
return segments;
}

File diff suppressed because one or more lines are too long