Phase4
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:32:27 +02:00
parent 9b061c3ee7
commit 4262464cd5
101 changed files with 4024 additions and 74 deletions

View File

@@ -190,7 +190,9 @@
"console": "Konsole",
"files": "Dateien",
"properties": "Eigenschaften",
"players": "Spieler"
"players": "Spieler",
"worlds": "Welten",
"backups": "Backups"
},
"console": {
"loading": "Wird geladen…",
@@ -274,6 +276,47 @@
"errors": {
"loadFailed": "Spielerliste konnte nicht geladen werden."
}
},
"worlds": {
"loading": "Wird geladen…",
"loadError": "Weltinformationen konnten nicht geladen werden.",
"activeWorld": "Aktive Welt: {name}",
"stoppedHint": "Upload und Ersetzen erfordern einen gestoppten Server.",
"worldName": "Weltname",
"uploadArchive": "Welt-Archiv (.tar.gz)",
"uploadError": "Welt-Upload fehlgeschlagen.",
"download": "Welt herunterladen",
"downloadError": "Download konnte nicht gestartet werden.",
"downloadPending": "Backup wird erstellt. Versuchen Sie den Download in Kürze erneut.",
"empty": "Keine Welten gefunden.",
"validWorld": "Gültige Welt",
"invalidWorld": "Ungültig",
"active": "Aktiv"
},
"backups": {
"loading": "Wird geladen…",
"loadError": "Backups konnten nicht geladen werden.",
"create": "Backup erstellen",
"manualLabel": "Manuelles Backup",
"createError": "Backup konnte nicht erstellt werden.",
"download": "Download",
"downloadError": "Download-URL konnte nicht erstellt werden.",
"restore": "Wiederherstellen",
"restoreConfirm": "Welt wirklich aus diesem Backup wiederherstellen? Der Server muss gestoppt sein.",
"restoreError": "Wiederherstellung fehlgeschlagen.",
"delete": "Löschen",
"deleteConfirm": "Backup wirklich löschen?",
"deleteError": "Backup konnte nicht gelöscht werden.",
"enableSchedule": "Geplante Backups aktivieren",
"disableSchedule": "Geplante Backups deaktivieren",
"scheduleError": "Zeitplan konnte nicht aktualisiert werden.",
"scheduleInfo": "Intervall: alle {hours}h · Aufbewahrung: {retention} Backups",
"label": "Bezeichnung",
"status": "Status",
"size": "Größe",
"created": "Erstellt",
"actions": "Aktionen",
"empty": "Noch keine Backups vorhanden."
}
}
}

View File

@@ -190,7 +190,9 @@
"console": "Console",
"files": "Files",
"properties": "Properties",
"players": "Players"
"players": "Players",
"worlds": "Worlds",
"backups": "Backups"
},
"console": {
"loading": "Loading…",
@@ -274,6 +276,47 @@
"errors": {
"loadFailed": "Could not load player list."
}
},
"worlds": {
"loading": "Loading…",
"loadError": "Could not load world information.",
"activeWorld": "Active world: {name}",
"stoppedHint": "Upload and replace require a stopped server.",
"worldName": "World name",
"uploadArchive": "World archive (.tar.gz)",
"uploadError": "World upload failed.",
"download": "Download world",
"downloadError": "Could not start download.",
"downloadPending": "Backup is being created. Try the download again shortly.",
"empty": "No worlds found.",
"validWorld": "Valid world",
"invalidWorld": "Invalid",
"active": "Active"
},
"backups": {
"loading": "Loading…",
"loadError": "Could not load backups.",
"create": "Create backup",
"manualLabel": "Manual backup",
"createError": "Could not create backup.",
"download": "Download",
"downloadError": "Could not create download URL.",
"restore": "Restore",
"restoreConfirm": "Restore the world from this backup? The server must be stopped.",
"restoreError": "Restore failed.",
"delete": "Delete",
"deleteConfirm": "Delete this backup?",
"deleteError": "Could not delete backup.",
"enableSchedule": "Enable scheduled backups",
"disableSchedule": "Disable scheduled backups",
"scheduleError": "Could not update schedule.",
"scheduleInfo": "Interval: every {hours}h · Retention: {retention} backups",
"label": "Label",
"status": "Status",
"size": "Size",
"created": "Created",
"actions": "Actions",
"empty": "No backups yet."
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
createBackup,
deleteBackup,
getBackupDownloadUrl,
getBackupSchedule,
listBackups,
restoreBackup,
updateBackupSchedule,
type BackupItem,
type BackupSchedule,
} from "@/lib/api/backups";
interface ServerBackupsProps {
serverId: string;
}
function formatBytes(value: string | null): string {
if (!value) {
return "—";
}
const bytes = Number(value);
if (Number.isNaN(bytes)) {
return value;
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function ServerBackups({ serverId }: ServerBackupsProps) {
const t = useTranslations("servers.backups");
const [backups, setBackups] = useState<BackupItem[]>([]);
const [schedule, setSchedule] = useState<BackupSchedule | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [backupList, scheduleData] = await Promise.all([
listBackups(serverId),
getBackupSchedule(serverId),
]);
setBackups(backupList.backups);
setSchedule(scheduleData);
} catch (err) {
setError(err instanceof Error ? err.message : t("loadError"));
} finally {
setLoading(false);
}
}, [serverId, t]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleCreateBackup() {
setBusy(true);
setError(null);
try {
await createBackup(serverId, t("manualLabel"));
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("createError"));
} finally {
setBusy(false);
}
}
async function handleDownload(backupId: string) {
try {
const { downloadUrl } = await getBackupDownloadUrl(serverId, backupId);
window.open(downloadUrl, "_blank", "noopener,noreferrer");
} catch (err) {
setError(err instanceof Error ? err.message : t("downloadError"));
}
}
async function handleRestore(backupId: string) {
if (!window.confirm(t("restoreConfirm"))) {
return;
}
setBusy(true);
try {
await restoreBackup(serverId, backupId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("restoreError"));
} finally {
setBusy(false);
}
}
async function handleDelete(backupId: string) {
if (!window.confirm(t("deleteConfirm"))) {
return;
}
setBusy(true);
try {
await deleteBackup(serverId, backupId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("deleteError"));
} finally {
setBusy(false);
}
}
async function toggleSchedule(enabled: boolean) {
if (!schedule) {
return;
}
setBusy(true);
try {
const updated = await updateBackupSchedule(serverId, { enabled });
setSchedule(updated);
} catch (err) {
setError(err instanceof Error ? err.message : t("scheduleError"));
} finally {
setBusy(false);
}
}
if (loading) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleCreateBackup()} disabled={busy}>
{t("create")}
</Button>
{schedule ? (
<Button
variant="secondary"
onClick={() => void toggleSchedule(!schedule.enabled)}
disabled={busy}
>
{schedule.enabled ? t("disableSchedule") : t("enableSchedule")}
</Button>
) : null}
</div>
{schedule ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{t("scheduleInfo", {
hours: schedule.intervalHours,
retention: schedule.retentionCount,
})}
</p>
) : null}
<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-2 text-left font-medium">{t("label")}</th>
<th className="px-4 py-2 text-left font-medium">{t("status")}</th>
<th className="px-4 py-2 text-left font-medium">{t("size")}</th>
<th className="px-4 py-2 text-left font-medium">{t("created")}</th>
<th className="px-4 py-2 text-right font-medium">{t("actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{backups.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-zinc-500">
{t("empty")}
</td>
</tr>
) : (
backups.map((backup) => (
<tr key={backup.id}>
<td className="px-4 py-2">{backup.label ?? backup.type}</td>
<td className="px-4 py-2">{backup.status}</td>
<td className="px-4 py-2">{formatBytes(backup.sizeBytes)}</td>
<td className="px-4 py-2">
{new Date(backup.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2 text-right space-x-2">
{backup.status === "AVAILABLE" ? (
<>
<Button
size="sm"
variant="secondary"
onClick={() => void handleDownload(backup.id)}
>
{t("download")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void handleRestore(backup.id)}
disabled={busy}
>
{t("restore")}
</Button>
</>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => void handleDelete(backup.id)}
disabled={busy}
>
{t("delete")}
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -13,6 +13,8 @@ const tabs = [
{ key: "files", suffix: "/files" },
{ key: "properties", suffix: "/properties" },
{ key: "players", suffix: "/players" },
{ key: "worlds", suffix: "/worlds" },
{ key: "backups", suffix: "/backups" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {

View File

@@ -0,0 +1,172 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
completeWorldUpload,
getWorldInfo,
initiateWorldUpload,
requestWorldDownload,
type WorldSummary,
} from "@/lib/api/worlds";
import { getBackupDownloadUrl } from "@/lib/api/backups";
interface ServerWorldsProps {
serverId: string;
}
function formatBytes(value: string): string {
const bytes = Number(value);
if (Number.isNaN(bytes)) {
return value;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function ServerWorlds({ serverId }: ServerWorldsProps) {
const t = useTranslations("servers.worlds");
const [activeWorldName, setActiveWorldName] = useState("world");
const [worlds, setWorlds] = useState<WorldSummary[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [worldName, setWorldName] = useState("world");
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const info = await getWorldInfo(serverId);
setActiveWorldName(info.activeWorldName);
setWorlds(info.worlds);
} catch (err) {
setError(err instanceof Error ? err.message : t("loadError"));
} finally {
setLoading(false);
}
}, [serverId, t]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleUpload(file: File) {
setBusy(true);
setError(null);
try {
const init = await initiateWorldUpload(serverId, worldName);
const response = await fetch(init.uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": "application/gzip" },
});
if (!response.ok) {
throw new Error(t("uploadError"));
}
await completeWorldUpload(serverId, init.uploadId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("uploadError"));
} finally {
setBusy(false);
}
}
async function handleDownload() {
setBusy(true);
setError(null);
try {
const { backupId } = await requestWorldDownload(serverId);
const { downloadUrl } = await getBackupDownloadUrl(serverId, backupId);
if (downloadUrl) {
window.open(downloadUrl, "_blank", "noopener,noreferrer");
} else {
setError(t("downloadPending"));
}
} catch (err) {
setError(err instanceof Error ? err.message : t("downloadError"));
} finally {
setBusy(false);
}
}
if (loading) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{t("activeWorld", { name: activeWorldName })}
</p>
<p className="mt-1 text-xs text-zinc-500">{t("stoppedHint")}</p>
</div>
<div className="flex flex-wrap gap-3 items-end">
<label className="block text-sm">
<span className="mb-1 block text-zinc-600 dark:text-zinc-400">
{t("worldName")}
</span>
<input
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
value={worldName}
onChange={(event) => setWorldName(event.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-600 dark:text-zinc-400">
{t("uploadArchive")}
</span>
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
disabled={busy}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleUpload(file);
}
}}
/>
</label>
<Button variant="secondary" onClick={() => void handleDownload()} disabled={busy}>
{t("download")}
</Button>
</div>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{worlds.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
) : (
worlds.map((world) => (
<li key={world.name} className="flex items-center justify-between px-4 py-3 text-sm">
<div>
<p className="font-medium">{world.name}</p>
<p className="text-zinc-500">
{world.hasLevelDat ? t("validWorld") : t("invalidWorld")} ·{" "}
{formatBytes(world.sizeBytes)}
</p>
</div>
{world.name === activeWorldName ? (
<span className="text-xs text-sky-600 dark:text-sky-400">{t("active")}</span>
) : null}
</li>
))
)}
</ul>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { apiFetch } from "./auth";
export interface BackupItem {
id: string;
serverId: string;
type: string;
status: string;
sizeBytes: string | null;
sha256: string | null;
label: string | null;
failureReason: string | null;
createdAt: string;
completedAt: string | null;
}
export interface BackupListResponse {
backups: BackupItem[];
}
export interface BackupSchedule {
enabled: boolean;
intervalHours: number;
retentionCount: number;
nextRunAt: string | null;
lastRunAt: string | null;
}
export async function listBackups(serverId: string): Promise<BackupListResponse> {
return apiFetch<BackupListResponse>(`/servers/${serverId}/backups`);
}
export async function createBackup(
serverId: string,
label?: string,
): Promise<{ backup: BackupItem; jobId?: string }> {
return apiFetch(`/servers/${serverId}/backups`, {
method: "POST",
body: JSON.stringify({ label }),
});
}
export async function deleteBackup(
serverId: string,
backupId: string,
): Promise<BackupItem> {
return apiFetch(`/servers/${serverId}/backups/${backupId}`, {
method: "DELETE",
});
}
export async function getBackupDownloadUrl(
serverId: string,
backupId: string,
): Promise<{ downloadUrl: string; expiresAt: string }> {
return apiFetch(`/servers/${serverId}/backups/${backupId}/download-url`);
}
export async function restoreBackup(
serverId: string,
backupId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/backups/${backupId}/restore`, {
method: "POST",
body: JSON.stringify({ confirm: true }),
});
}
export async function getBackupSchedule(serverId: string): Promise<BackupSchedule> {
return apiFetch(`/servers/${serverId}/backups/schedule`);
}
export async function updateBackupSchedule(
serverId: string,
input: Partial<BackupSchedule>,
): Promise<BackupSchedule> {
return apiFetch(`/servers/${serverId}/backups/schedule`, {
method: "PATCH",
body: JSON.stringify(input),
});
}

View File

@@ -0,0 +1,48 @@
import { apiFetch } from "./auth";
export interface WorldSummary {
name: string;
path: string;
sizeBytes: string;
hasLevelDat: boolean;
}
export interface WorldInfoResponse {
activeWorldName: string;
worlds: WorldSummary[];
}
export async function getWorldInfo(serverId: string): Promise<WorldInfoResponse> {
return apiFetch(`/servers/${serverId}/worlds`);
}
export async function initiateWorldUpload(
serverId: string,
worldName: string,
): Promise<{
uploadId: string;
uploadUrl: string;
expiresAt: string;
}> {
return apiFetch(`/servers/${serverId}/worlds/uploads`, {
method: "POST",
body: JSON.stringify({ worldName }),
});
}
export async function completeWorldUpload(
serverId: string,
uploadId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/worlds/uploads/${uploadId}/complete`, {
method: "POST",
});
}
export async function requestWorldDownload(
serverId: string,
): Promise<{ backupId: string }> {
return apiFetch(`/servers/${serverId}/worlds/download`, {
method: "POST",
});
}