mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
✨ feat: add plugin registry and server operations
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { PluginRegistry } from "@/components/plugin-registry";
|
||||
|
||||
export default function PluginsPage() {
|
||||
return <PluginRegistry />;
|
||||
}
|
||||
@@ -35,6 +35,11 @@ export default function CreateServerPage() {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const pluginResponse = await api.api.registry
|
||||
.servers({ serverId: payload.id })
|
||||
.plugins.put({ artifactIds: data.registryArtifactIds });
|
||||
if (pluginResponse.error) throw new Error("Server created, but plugin deployment failed");
|
||||
|
||||
router.push("/dashboard/servers");
|
||||
};
|
||||
|
||||
|
||||
@@ -95,6 +95,13 @@ export default function EditServerPage() {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
if (resourceKind === "server") {
|
||||
const pluginResponse = await api.api.registry
|
||||
.servers({ serverId })
|
||||
.plugins.put({ artifactIds: data.registryArtifactIds });
|
||||
if (pluginResponse.error) throw new Error("Server updated, but plugin deployment failed");
|
||||
}
|
||||
|
||||
router.push("/dashboard/servers");
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import type { NormalServer, PodInfo } from "@minikura/api";
|
||||
import { ArrowLeft, Play, RefreshCw, ServerIcon, Square } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { Terminal } from "@/components/terminal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export default function ManageServerPage() {
|
||||
const router = useRouter();
|
||||
const serverId = useParams<{ id: string }>().id;
|
||||
const [server, setServer] = useState<NormalServer | null>(null);
|
||||
const [pods, setPods] = useState<PodInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
const [serverResponse, podsResponse] = await Promise.all([
|
||||
api.api.servers({ id: serverId }).get(),
|
||||
api.api.k8s.servers({ serverId }).pods.get(),
|
||||
]);
|
||||
if (serverResponse.error) throw serverResponse.error;
|
||||
if (podsResponse.error) throw podsResponse.error;
|
||||
setServer(serverResponse.data as NormalServer);
|
||||
setPods((podsResponse.data ?? []) as PodInfo[]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
api.api.servers({ id: serverId }).get(),
|
||||
api.api.k8s.servers({ serverId }).pods.get(),
|
||||
])
|
||||
.then(([serverResponse, podsResponse]) => {
|
||||
if (serverResponse.error) throw serverResponse.error;
|
||||
if (podsResponse.error) throw podsResponse.error;
|
||||
setServer(serverResponse.data as NormalServer);
|
||||
setPods((podsResponse.data ?? []) as PodInfo[]);
|
||||
})
|
||||
.catch(() => setError("Unable to load server operations"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [serverId]);
|
||||
|
||||
const action = async (kind: "start" | "stop" | "restart") => {
|
||||
setActing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.api.servers({ id: serverId }).actions[kind].post();
|
||||
if (response.error) throw response.error;
|
||||
await refresh();
|
||||
} catch {
|
||||
setError(`Failed to ${kind} server`);
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return <StatePanel loading title="Loading server operations..." className="min-h-[50vh]" />;
|
||||
if (!server) return <StatePanel title="Server not found" tone="error" />;
|
||||
|
||||
const pod = pods.find((candidate) => candidate.status === "Running") ?? pods[0];
|
||||
const running = server.running !== false;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="Workloads / Operations"
|
||||
title={server.id}
|
||||
description="Live console access and workload lifecycle controls."
|
||||
leading={
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/dashboard/servers")}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={running ? "default" : "secondary"}>
|
||||
{running ? "Desired: running" : "Stopped"}
|
||||
</Badge>
|
||||
{running ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={acting || !pod}
|
||||
onClick={() => void action("restart")}
|
||||
>
|
||||
<RefreshCw className="size-4" /> Restart
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={acting} onClick={() => void action("stop")}>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button disabled={acting} onClick={() => void action("start")}>
|
||||
<Play className="size-4" /> Start
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="flex-row items-center justify-start"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="border-l-2 border-destructive pl-3 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="border bg-card p-4">
|
||||
<p className="font-mono text-[10px] uppercase text-muted-foreground">Pod</p>
|
||||
<p className="mt-1 truncate font-bold">{pod?.name ?? "Not scheduled"}</p>
|
||||
</div>
|
||||
<div className="border bg-card p-4">
|
||||
<p className="font-mono text-[10px] uppercase text-muted-foreground">Runtime</p>
|
||||
<p className="mt-1 font-bold">
|
||||
{server.jar_type} {server.minecraft_version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border bg-card p-4">
|
||||
<p className="font-mono text-[10px] uppercase text-muted-foreground">Status</p>
|
||||
<p className="mt-1 font-bold">
|
||||
{pod ? `${pod.status} · ${pod.ready} ready` : running ? "Starting" : "Stopped"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionCard
|
||||
title="Console"
|
||||
description="Attach to Minecraft output, send server commands, or open a container shell."
|
||||
icon={<ServerIcon className="size-5 text-primary" />}
|
||||
headerAction={
|
||||
<Button variant="ghost" size="sm" disabled={acting} onClick={() => void refresh()}>
|
||||
<RefreshCw className="size-4" /> Refresh
|
||||
</Button>
|
||||
}
|
||||
contentClassName="p-0"
|
||||
>
|
||||
{!pod ? (
|
||||
<StatePanel
|
||||
title={running ? "Waiting for the Minecraft pod" : "Server is stopped"}
|
||||
description={
|
||||
running
|
||||
? "Refresh once Kubernetes has scheduled the workload."
|
||||
: "Start the server to access logs and console."
|
||||
}
|
||||
className="m-6 min-h-72"
|
||||
/>
|
||||
) : (
|
||||
<Tabs defaultValue="console" className="gap-0">
|
||||
<TabsList className="mx-5 mt-4 sm:mx-6">
|
||||
<TabsTrigger value="console">Live Console</TabsTrigger>
|
||||
<TabsTrigger value="shell">Container Shell</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="console" className="h-[34rem] bg-black p-2">
|
||||
<Terminal
|
||||
key={`${pod.name}-console`}
|
||||
podName={pod.name}
|
||||
container="minecraft"
|
||||
mode="console"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="shell" className="h-[34rem] bg-black p-2">
|
||||
<Terminal
|
||||
key={`${pod.name}-shell`}
|
||||
podName={pod.name}
|
||||
container="minecraft"
|
||||
mode="shell"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</SectionCard>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,9 @@ export default function ServersPage() {
|
||||
type="normal"
|
||||
servers={normalServers}
|
||||
onEdit={isAdmin ? (id) => router.push(`/dashboard/servers/edit/${id}`) : undefined}
|
||||
onManage={
|
||||
isAdmin ? (id) => router.push(`/dashboard/servers/manage/${id}`) : undefined
|
||||
}
|
||||
onDelete={isAdmin ? (id) => setDeleteTarget({ id, type: "normal" }) : undefined}
|
||||
/>
|
||||
</ResourceSection>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GitGraph, LogOut, type LucideIcon, Network, Server, Users } from "lucide-react";
|
||||
import { GitGraph, LogOut, type LucideIcon, Network, Package, Server, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
@@ -55,6 +55,13 @@ const navigation: NavigationGroup[] = [
|
||||
adminOnly: true,
|
||||
},
|
||||
{ href: "/dashboard/servers", icon: Server, label: "Servers", context: "Workloads" },
|
||||
{
|
||||
href: "/dashboard/plugins",
|
||||
icon: Package,
|
||||
label: "Plugins",
|
||||
context: "Registry",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
href: "/dashboard/topology",
|
||||
icon: GitGraph,
|
||||
@@ -94,8 +101,10 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
(pathname === "/dashboard/users" ||
|
||||
pathname.startsWith("/dashboard/topology") ||
|
||||
pathname.startsWith("/dashboard/k8s") ||
|
||||
pathname.startsWith("/dashboard/plugins") ||
|
||||
pathname.startsWith("/dashboard/servers/create") ||
|
||||
pathname.startsWith("/dashboard/servers/edit"))
|
||||
pathname.startsWith("/dashboard/servers/edit") ||
|
||||
pathname.startsWith("/dashboard/servers/manage"))
|
||||
) {
|
||||
router.replace("/dashboard/servers");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
"use client";
|
||||
|
||||
import { HardDriveUpload, LoaderCircle, Package, PackageSearch, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
type Project = {
|
||||
provider: "MODRINTH" | "HANGAR";
|
||||
projectId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
iconUrl: string | null;
|
||||
downloads: number;
|
||||
license: string | null;
|
||||
author: string;
|
||||
categories: string[];
|
||||
minecraftVersions: string[];
|
||||
updatedAt: string | null;
|
||||
projectUrl: string;
|
||||
};
|
||||
|
||||
type Version = {
|
||||
provider: "MODRINTH" | "HANGAR";
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
versionId: string;
|
||||
version: string;
|
||||
platform: "PAPER" | "FOLIA" | "VELOCITY";
|
||||
minecraftVersions: string[];
|
||||
filename: string;
|
||||
size: number;
|
||||
sha256: string | null;
|
||||
downloadUrl: string;
|
||||
license: string | null;
|
||||
description?: string;
|
||||
author?: string;
|
||||
iconUrl?: string | null;
|
||||
projectUrl?: string;
|
||||
categories?: string[];
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
type Artifact = {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
version: string;
|
||||
filename: string;
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
icon_url: string | null;
|
||||
project_url: string | null;
|
||||
categories: string[];
|
||||
server_plugins: Array<{ server_id: string }>;
|
||||
};
|
||||
|
||||
export function PluginRegistry() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Artifact | null>(null);
|
||||
const platform = "PAPER" as const;
|
||||
|
||||
const loadArtifacts = async () => {
|
||||
const response = await api.api.registry.artifacts.get();
|
||||
if (response.error) throw response.error;
|
||||
setArtifacts((response.data ?? []) as Artifact[]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void api.api.registry.artifacts
|
||||
.get()
|
||||
.then(({ data, error: responseError }) => {
|
||||
if (responseError) throw responseError;
|
||||
setArtifacts((data ?? []) as Artifact[]);
|
||||
})
|
||||
.catch((loadError) =>
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load registry")
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length > 0) return;
|
||||
void api.api.registry.search
|
||||
.get({
|
||||
query: {
|
||||
query: "",
|
||||
platform,
|
||||
},
|
||||
})
|
||||
.then(({ data, error: responseError }) => {
|
||||
if (responseError) throw responseError;
|
||||
setProjects((data ?? []) as Project[]);
|
||||
})
|
||||
.catch((loadError) =>
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load top plugins")
|
||||
);
|
||||
}, [platform, projects.length]);
|
||||
|
||||
const search = async () => {
|
||||
if (!query.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSelectedProject(null);
|
||||
setVersions([]);
|
||||
try {
|
||||
const response = await api.api.registry.search.get({
|
||||
query: {
|
||||
query,
|
||||
platform,
|
||||
},
|
||||
});
|
||||
if (response.error) throw response.error;
|
||||
setProjects((response.data ?? []) as Project[]);
|
||||
} catch (searchError) {
|
||||
setError(searchError instanceof Error ? searchError.message : "Search failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProject = async (project: Project) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSelectedProject(project);
|
||||
try {
|
||||
const response = await api.api.registry.versions.get({
|
||||
query: {
|
||||
provider: project.provider,
|
||||
projectId: project.projectId,
|
||||
platform,
|
||||
},
|
||||
});
|
||||
if (response.error) throw response.error;
|
||||
setVersions((response.data ?? []) as Version[]);
|
||||
} catch (versionError) {
|
||||
setError(versionError instanceof Error ? versionError.message : "Failed to load versions");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const store = async (version: Version) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.api.registry.artifacts.post({
|
||||
...version,
|
||||
projectName: selectedProject?.name,
|
||||
description: selectedProject?.description,
|
||||
author: selectedProject?.author,
|
||||
iconUrl: selectedProject?.iconUrl,
|
||||
projectUrl: selectedProject?.projectUrl,
|
||||
categories: selectedProject?.categories,
|
||||
updatedAt: selectedProject?.updatedAt,
|
||||
});
|
||||
if (response.error) throw response.error;
|
||||
await loadArtifacts();
|
||||
} catch (installError) {
|
||||
setError(installError instanceof Error ? installError.message : "Registry import failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.api.registry.artifacts.upload.post({ file });
|
||||
if (response.error) throw response.error;
|
||||
await loadArtifacts();
|
||||
} catch (uploadError) {
|
||||
setError(uploadError instanceof Error ? uploadError.message : "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteArtifact = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.api.registry.library({ artifactId: deleteTarget.id }).delete();
|
||||
if (response.error) throw response.error;
|
||||
setDeleteTarget(null);
|
||||
await loadArtifacts();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete artifact");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="Artifacts"
|
||||
title="Plugin Registry"
|
||||
description="Discover releases and maintain a global plugin artifact library. Assign plugins from a server's Mods/Plugins settings."
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="discover" className="gap-5">
|
||||
<TabsList className="w-full sm:w-fit">
|
||||
<TabsTrigger value="discover">
|
||||
<PackageSearch className="size-4" /> Discover
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="library">
|
||||
<Package className="size-4" /> Library
|
||||
<span className="ml-1 rounded-full bg-background/15 px-1.5 py-0.5 text-[9px]">
|
||||
{artifacts.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="discover">
|
||||
<SectionCard
|
||||
title="Catalog"
|
||||
description="Browse Paper-compatible plugins from Modrinth and Hangar"
|
||||
icon={<PackageSearch className="size-5 text-primary" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder="Search ViaVersion, LuckPerms, CoreProtect..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || !query.trim()}
|
||||
onClick={() => void search()}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PackageSearch className="size-4" />
|
||||
)}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="border-l-2 border-destructive pl-3 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedProject ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProject.iconUrl ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={`${selectedProject.name} icon`}
|
||||
className="size-12 rounded-sm border bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${selectedProject.iconUrl})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid size-12 place-items-center border bg-muted">
|
||||
<Package className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-bold">{selectedProject.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
by {selectedProject.author} ·{" "}
|
||||
{selectedProject.downloads.toLocaleString()} downloads
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 max-w-3xl text-sm text-muted-foreground">
|
||||
{selectedProject.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline">{selectedProject.provider}</Badge>
|
||||
{selectedProject.license && (
|
||||
<Badge variant="secondary">{selectedProject.license}</Badge>
|
||||
)}
|
||||
{selectedProject.categories.slice(0, 4).map((category) => (
|
||||
<Badge key={category} variant="secondary">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<a href={selectedProject.projectUrl} target="_blank" rel="noreferrer">
|
||||
Provider page
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedProject(null)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!busy && versions.length === 0 && (
|
||||
<StatePanel title="No compatible release found" className="min-h-32" />
|
||||
)}
|
||||
{versions.slice(0, 15).map((version) => (
|
||||
<div
|
||||
key={`${version.provider}-${version.versionId}-${version.platform}`}
|
||||
className="flex items-center justify-between gap-3 border bg-background px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-bold">{version.version}</p>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{version.filename} · {(version.size / 1024 / 1024).toFixed(1)} MiB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void store(version)}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projects.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{!query.trim() && (
|
||||
<div className="flex items-end justify-between border-b pb-3">
|
||||
<div>
|
||||
<p className="font-bold">Top plugins</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Popular projects ranked by downloads
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{projects.length} projects</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{projects.map((project, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${project.provider}-${project.projectId}`}
|
||||
className="group relative space-y-3 border bg-background p-4 text-left transition-colors hover:border-primary"
|
||||
onClick={() => void selectProject(project)}
|
||||
>
|
||||
{!query.trim() && (
|
||||
<span className="absolute right-3 top-3 font-mono text-2xl font-black text-muted-foreground/20">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
{project.iconUrl ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={`${project.name} icon`}
|
||||
className="size-12 shrink-0 rounded-sm border bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${project.iconUrl})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid size-12 shrink-0 place-items-center border bg-muted">
|
||||
<Package className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-bold group-hover:text-primary">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
by {project.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline">{project.provider}</Badge>
|
||||
{project.categories.slice(0, 2).map((category) => (
|
||||
<Badge key={category} variant="secondary">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 font-mono text-[10px] uppercase text-muted-foreground">
|
||||
<span>{project.downloads.toLocaleString()} downloads</span>
|
||||
<span>{project.license ?? "License unspecified"}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<StatePanel
|
||||
title="Search the public catalog"
|
||||
description="Results combine Modrinth and PaperMC Hangar."
|
||||
icon={<Package className="size-7" />}
|
||||
className="min-h-56"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="library" className="space-y-6">
|
||||
<SectionCard
|
||||
title="Artifact Library"
|
||||
description="Provider releases and private JARs stored independently of server deployments."
|
||||
icon={<Package className="size-5 text-primary" />}
|
||||
headerAction={
|
||||
<Button type="button" size="sm" asChild disabled={busy}>
|
||||
<label className="cursor-pointer">
|
||||
<HardDriveUpload className="size-4" /> Upload private JAR
|
||||
<input
|
||||
type="file"
|
||||
accept=".jar,application/java-archive"
|
||||
className="sr-only"
|
||||
onChange={(event) => void upload(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{artifacts.length === 0 ? (
|
||||
<StatePanel
|
||||
title="Your library is empty"
|
||||
description="Store a release from Discover or upload a private plugin JAR."
|
||||
icon={<Package className="size-7" />}
|
||||
className="min-h-52"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{artifacts.map((artifact) => (
|
||||
<article
|
||||
key={artifact.id}
|
||||
className="flex min-h-40 flex-col justify-between gap-4 border bg-background p-4 transition-colors hover:border-foreground/40"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{artifact.icon_url ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={`${artifact.name} icon`}
|
||||
className="size-10 shrink-0 rounded-sm border bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${artifact.icon_url})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid size-10 shrink-0 place-items-center border bg-muted">
|
||||
<Package className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<Badge variant={artifact.provider === "UPLOAD" ? "secondary" : "outline"}>
|
||||
{artifact.provider === "UPLOAD" ? "Private" : artifact.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate font-bold">{artifact.name}</h3>
|
||||
{artifact.author && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
by {artifact.author}
|
||||
</p>
|
||||
)}
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{artifact.version} · {artifact.filename}
|
||||
</p>
|
||||
</div>
|
||||
{artifact.description && (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{artifact.description}
|
||||
</p>
|
||||
)}
|
||||
{artifact.categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{artifact.categories.slice(0, 3).map((category) => (
|
||||
<Badge key={category} variant="secondary">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deployed to {artifact.server_plugins.length}{" "}
|
||||
{artifact.server_plugins.length === 1 ? "server" : "servers"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Assign from Create/Edit Server
|
||||
</span>
|
||||
{artifact.project_url && (
|
||||
<Button type="button" variant="ghost" size="sm" asChild>
|
||||
<a href={artifact.project_url} target="_blank" rel="noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleteTarget(artifact)}
|
||||
aria-label={`Delete ${artifact.name} from registry`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Delete Plugin Artifact"
|
||||
description={
|
||||
<>
|
||||
Delete <strong>{deleteTarget?.name}</strong> from the global registry? This removes it
|
||||
from every server and permanently deletes private S3 content.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete Artifact"
|
||||
onConfirm={deleteArtifact}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdvancedPanel } from "@/components/server-form/advanced-panel";
|
||||
import { AutomationPanel } from "@/components/server-form/automation-panel";
|
||||
import { BasicPanel } from "@/components/server-form/basic-panel";
|
||||
@@ -14,6 +14,7 @@ import type { ServerFormData, UpdateServerField } from "@/components/server-form
|
||||
import { WorldPanel } from "@/components/server-form/world-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export type {
|
||||
Difficulty,
|
||||
@@ -87,6 +88,7 @@ export function ServerForm({
|
||||
autostopTimeoutInit: initialData?.autostopTimeoutInit || "1800",
|
||||
autostopPeriod: initialData?.autostopPeriod || "10",
|
||||
removeOldPlugins: initialData?.removeOldPlugins ?? false,
|
||||
registryArtifactIds: initialData?.registryArtifactIds ?? [],
|
||||
timezone: initialData?.timezone || "UTC",
|
||||
uid: initialData?.uid || "1000",
|
||||
gid: initialData?.gid || "1000",
|
||||
@@ -101,6 +103,22 @@ export function ServerForm({
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData?.id) return;
|
||||
void api.api.registry
|
||||
.servers({ serverId: initialData.id })
|
||||
.plugins.get()
|
||||
.then(({ data, error: responseError }) => {
|
||||
if (responseError) throw responseError;
|
||||
const plugins = (data ?? []) as Array<{ artifact_id: string }>;
|
||||
setFormData((previous) => ({
|
||||
...previous,
|
||||
registryArtifactIds: plugins.map((plugin) => plugin.artifact_id),
|
||||
}));
|
||||
})
|
||||
.catch(() => setError("Failed to load deployed registry plugins"));
|
||||
}, [initialData?.id]);
|
||||
|
||||
const updateField: UpdateServerField = (key, value) => {
|
||||
setFormData((previous) => ({ ...previous, [key]: value }));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Package } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
type Artifact = {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
version: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export function ArtifactSelector({ formData, updateField }: ServerFormPanelProps) {
|
||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.api.registry.artifacts
|
||||
.get()
|
||||
.then(({ data, error: responseError }) => {
|
||||
if (responseError) throw responseError;
|
||||
setArtifacts((data ?? []) as Artifact[]);
|
||||
})
|
||||
.catch((loadError) =>
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load plugins")
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggle = (artifactId: string, checked: boolean) => {
|
||||
updateField(
|
||||
"registryArtifactIds",
|
||||
checked
|
||||
? [...new Set([...formData.registryArtifactIds, artifactId])]
|
||||
: formData.registryArtifactIds.filter((id) => id !== artifactId)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border p-4">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold">Plugin Library</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select stored artifacts to deploy when this server is saved.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{formData.registryArtifactIds.length} selected</Badge>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{artifacts.length === 0 ? (
|
||||
<div className="flex items-center gap-3 border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<Package className="size-5" /> Store or upload plugins from the global Plugins page first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-72 gap-2 overflow-y-auto md:grid-cols-2">
|
||||
{artifacts.map((artifact) => {
|
||||
const checked = formData.registryArtifactIds.includes(artifact.id);
|
||||
return (
|
||||
<div
|
||||
key={artifact.id}
|
||||
className="flex items-start gap-3 border bg-background p-3 hover:border-primary"
|
||||
>
|
||||
<Checkbox
|
||||
id={`artifact-${artifact.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => toggle(artifact.id, value === true)}
|
||||
/>
|
||||
<label htmlFor={`artifact-${artifact.id}`} className="min-w-0 cursor-pointer">
|
||||
<span className="block truncate text-sm font-bold">{artifact.name}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-muted-foreground">
|
||||
{artifact.provider} · {artifact.version} · {artifact.filename}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import { ArtifactSelector } from "./artifact-selector";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
@@ -11,6 +12,9 @@ export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
|
||||
)}
|
||||
{["PAPER", "SPIGOT", "PURPUR"].includes(formData.type) && (
|
||||
<ArtifactSelector formData={formData} updateField={updateField} />
|
||||
)}
|
||||
<Field
|
||||
id="plugins"
|
||||
label="Plugins"
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface ServerFormData {
|
||||
autostopTimeoutInit: string;
|
||||
autostopPeriod: string;
|
||||
plugins?: string;
|
||||
registryArtifactIds: string[];
|
||||
removeOldPlugins: boolean;
|
||||
spigetResources?: string;
|
||||
paperBuild?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NormalServer, ReverseProxyServer } from "@minikura/api";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, SquareTerminal, Trash2 } from "lucide-react";
|
||||
import { DataTable, type DataTableColumn } from "@/components/data-table";
|
||||
import { TableActions } from "@/components/section-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -12,6 +12,7 @@ type ServerTableProps =
|
||||
type: "normal";
|
||||
servers: NormalServer[];
|
||||
onEdit?: (id: string) => void;
|
||||
onManage?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
| {
|
||||
@@ -26,15 +27,27 @@ function RowActions({
|
||||
kind,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onManage,
|
||||
}: {
|
||||
id: string;
|
||||
kind: string;
|
||||
onEdit?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onManage?: (id: string) => void;
|
||||
}) {
|
||||
if (!onEdit && !onDelete) return null;
|
||||
if (!onEdit && !onDelete && !onManage) return null;
|
||||
return (
|
||||
<TableActions>
|
||||
{onManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onManage(id)}
|
||||
aria-label={`Manage ${kind} ${id}`}
|
||||
>
|
||||
<SquareTerminal />
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -106,6 +119,7 @@ export function ServerTable(props: ServerTableProps) {
|
||||
id={server.id}
|
||||
kind="server"
|
||||
onEdit={props.onEdit}
|
||||
onManage={props.onManage}
|
||||
onDelete={props.onDelete}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { LigaturesAddon } from "@xterm/addon-ligatures";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
@@ -16,7 +14,7 @@ type TerminalProps = {
|
||||
podName: string;
|
||||
container: string;
|
||||
shell?: string;
|
||||
mode?: "shell" | "attach";
|
||||
mode?: "shell" | "console";
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
@@ -89,14 +87,12 @@ export function Terminal({
|
||||
const searchAddon = new SearchAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const imageAddon = new ImageAddon();
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
term.loadAddon(searchAddon);
|
||||
term.loadAddon(clipboardAddon);
|
||||
term.loadAddon(unicode11Addon);
|
||||
term.loadAddon(imageAddon);
|
||||
|
||||
term.unicode.activeVersion = "11";
|
||||
|
||||
@@ -113,13 +109,6 @@ export function Terminal({
|
||||
fitAddonRef.current = fitAddon;
|
||||
searchAddonRef.current = searchAddon;
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
} catch (_e) {}
|
||||
}, 100);
|
||||
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "f") {
|
||||
event.preventDefault();
|
||||
@@ -130,14 +119,19 @@ export function Terminal({
|
||||
});
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:3000/api/terminal/exec?podName=${encodeURIComponent(podName)}&container=${encodeURIComponent(container)}&shell=${encodeURIComponent(shell)}&mode=${mode}`;
|
||||
const wsUrl = new URL("/api/terminal/exec", window.location.origin);
|
||||
wsUrl.protocol = protocol;
|
||||
wsUrl.searchParams.set("podName", podName);
|
||||
wsUrl.searchParams.set("container", container);
|
||||
wsUrl.searchParams.set("shell", shell);
|
||||
wsUrl.searchParams.set("mode", mode);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
term.writeln(
|
||||
`\r\n\x1b[1;32mConnecting to ${mode === "attach" ? "container" : "shell"}...\x1b[0m\r\n`
|
||||
`\r\n\x1b[1;32mConnecting to ${mode === "console" ? "Minecraft console" : "shell"}...\x1b[0m\r\n`
|
||||
);
|
||||
|
||||
const { cols, rows } = term;
|
||||
@@ -168,8 +162,11 @@ export function Terminal({
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.writeln("\r\n\x1b[1;33mConnection closed\x1b[0m\r\n");
|
||||
ws.onclose = (event) => {
|
||||
const detail =
|
||||
event.reason ||
|
||||
(event.code === 1006 ? "Connection closed unexpectedly" : "Connection closed");
|
||||
term.writeln(`\r\n\x1b[1;33m${detail} (${event.code})\x1b[0m\r\n`);
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user