Add panel feature updates across API, daemon, and web

This commit is contained in:
2026-03-02 21:53:54 +00:00
parent 6b463c2b1a
commit afc64b83c1
49 changed files with 7040 additions and 305 deletions
+28 -20
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Settings2, FileText, Save } from 'lucide-react';
@@ -30,6 +30,24 @@ interface ConfigDetail {
raw: string;
}
function mergeConfigEntries(
entries: ConfigEntry[],
editableKeys: string[] | null,
): ConfigEntry[] {
if (!editableKeys || editableKeys.length === 0) return entries;
const existing = new Map(entries.map((entry) => [entry.key, entry]));
const merged = [...entries];
for (const key of editableKeys) {
if (!existing.has(key)) {
merged.push({ key, value: '' });
}
}
return merged;
}
export function ConfigPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
@@ -102,13 +120,11 @@ function ConfigEditor({
});
const [entries, setEntries] = useState<ConfigEntry[]>([]);
const [initialized, setInitialized] = useState(false);
// Initialize entries from server data
if (detail && !initialized) {
setEntries(detail.entries);
setInitialized(true);
}
useEffect(() => {
if (!detail) return;
setEntries(mergeConfigEntries(detail.entries, configFile.editableKeys));
}, [detail, configFile.editableKeys]);
const saveMutation = useMutation({
mutationFn: (data: { entries: ConfigEntry[] }) =>
@@ -129,14 +145,6 @@ function ConfigEditor({
);
};
const displayEntries = configFile.editableKeys
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
: entries;
const entriesToSave = configFile.editableKeys
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
: entries;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@@ -147,13 +155,13 @@ function ConfigEditor({
</CardTitle>
<CardDescription>
{configFile.editableKeys
? `${configFile.editableKeys.length} editable keys`
: 'All keys editable'}
? `${configFile.editableKeys.length} allowed additions, plus existing keys`
: 'All detected keys editable'}
</CardDescription>
</div>
<Button
size="sm"
onClick={() => saveMutation.mutate({ entries: entriesToSave })}
onClick={() => saveMutation.mutate({ entries })}
disabled={saveMutation.isPending}
>
<Save className="h-4 w-4" />
@@ -161,13 +169,13 @@ function ConfigEditor({
</Button>
</CardHeader>
<CardContent>
{displayEntries.length === 0 ? (
{entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'}
</p>
) : (
<div className="space-y-3">
{displayEntries.map((entry) => (
{entries.map((entry) => (
<div key={entry.key} className="grid gap-1.5">
<Label className="font-mono text-xs text-muted-foreground">
{entry.key}
+79 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useOutletContext, useParams } from 'react-router';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
@@ -10,17 +10,50 @@ import { Button } from '@/components/ui/button';
import { Send } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
interface ConsoleOutletContext {
server?: {
status: string;
};
}
export function ConsolePage() {
const { orgId, serverId } = useParams();
const { server } = useOutletContext<ConsoleOutletContext>();
const termRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serverStatusRef = useRef<string | null>(server?.status ?? null);
const rejoinTimeoutRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const [command, setCommand] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
useEffect(() => {
if (!termRef.current) return;
serverStatusRef.current = server?.status ?? null;
}, [server?.status]);
useEffect(() => {
if (!termRef.current || !serverId) return;
const joinConsole = () => {
connectSocket();
const socket = getSocket();
socket.emit('server:console:join', { serverId });
};
const scheduleRejoin = (delayMs = 1_000) => {
const status = serverStatusRef.current;
if (status !== 'starting' && status !== 'running') return;
if (rejoinTimeoutRef.current) {
window.clearTimeout(rejoinTimeoutRef.current);
}
rejoinTimeoutRef.current = window.setTimeout(() => {
rejoinTimeoutRef.current = null;
joinConsole();
}, delayMs);
};
const terminal = new Terminal({
cursorBlink: false,
@@ -48,33 +81,72 @@ export function ConsolePage() {
terminal.writeln('\x1b[90m--- Console connected ---\x1b[0m');
// Socket.IO connection
connectSocket();
const socket = getSocket();
socket.emit('server:console:join', { serverId });
const handleConnect = () => {
joinConsole();
};
const handleOutput = (data: { line: string }) => {
terminal.writeln(data.line);
if (data.line === '[console] Stream ended') {
scheduleRejoin();
}
};
const handleCommandAck = (data: { ok: boolean; error?: string }) => {
if (!data.ok && data.error) {
terminal.writeln(`[error] ${data.error}`);
}
};
socket.on('connect', handleConnect);
socket.on('server:console:output', handleOutput);
socket.on('server:console:command:ack', handleCommandAck);
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
joinConsole();
return () => {
if (rejoinTimeoutRef.current) {
window.clearTimeout(rejoinTimeoutRef.current);
rejoinTimeoutRef.current = null;
}
socket.off('connect', handleConnect);
socket.off('server:console:output', handleOutput);
socket.off('server:console:command:ack', handleCommandAck);
socket.emit('server:console:leave', { serverId });
window.removeEventListener('resize', handleResize);
terminal.dispose();
};
}, [serverId]);
useEffect(() => {
if (!serverId) return;
const status = server?.status;
if (status !== 'starting' && status !== 'running') {
if (rejoinTimeoutRef.current) {
window.clearTimeout(rejoinTimeoutRef.current);
rejoinTimeoutRef.current = null;
}
return;
}
connectSocket();
const socket = getSocket();
socket.emit('server:console:join', { serverId });
}, [server?.status, serverId]);
const sendCommand = () => {
if (!command.trim()) return;
const socket = getSocket();
socket.emit('server:console:command', { serverId, orgId, command: command.trim() });
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
socket.emit('server:console:command', {
serverId,
orgId,
command: command.trim(),
requestId,
});
setHistory((prev) => [...prev, command.trim()]);
setHistoryIndex(-1);
setCommand('');
+320
View File
@@ -0,0 +1,320 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, ExternalLink, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface ManagedDatabase {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
createdAt: string;
updatedAt: string;
}
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">
{value}
</div>
</div>
);
}
export function DatabasesPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [editingDatabase, setEditingDatabase] = useState<ManagedDatabase | null>(null);
const [editName, setEditName] = useState('');
const [editPassword, setEditPassword] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['server-databases', orgId, serverId],
queryFn: () =>
api.get<{ data: ManagedDatabase[] }>(
`/organizations/${orgId}/servers/${serverId}/databases`,
),
});
useEffect(() => {
if (!editingDatabase) return;
setEditName(editingDatabase.name);
setEditPassword('');
}, [editingDatabase]);
const databases = data?.data ?? [];
const resetCreateForm = () => {
setCreateName('');
setCreatePassword('');
};
const createMutation = useMutation({
mutationFn: (body: { name: string; password?: string }) =>
api.post<ManagedDatabase>(`/organizations/${orgId}/servers/${serverId}/databases`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
setCreateOpen(false);
resetCreateForm();
toast.success('Database created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create database'));
},
});
const updateMutation = useMutation({
mutationFn: (body: { name?: string; password?: string }) =>
api.patch<ManagedDatabase>(
`/organizations/${orgId}/servers/${serverId}/databases/${editingDatabase!.id}`,
body,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
setEditingDatabase(null);
setEditPassword('');
toast.success('Database updated');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update database'));
},
});
const deleteMutation = useMutation({
mutationFn: (databaseId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/databases/${databaseId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
toast.success('Database deleted');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to delete database'));
},
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Databases</h2>
<p className="text-sm text-muted-foreground">
Unlimited MySQL databases for this server, with password rotation and phpMyAdmin links.
</p>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" /> Create Database
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create MySQL Database</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
createMutation.mutate({
name: createName,
password: createPassword.trim() || undefined,
});
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
placeholder="LuckPerms"
required
/>
</div>
<div className="space-y-2">
<Label>Password (Optional)</Label>
<Input
value={createPassword}
onChange={(event) => setCreatePassword(event.target.value)}
minLength={8}
placeholder="Leave empty to auto-generate"
/>
<p className="text-xs text-muted-foreground">
If left empty, the panel generates a strong password automatically.
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Dialog
open={Boolean(editingDatabase)}
onOpenChange={(open) => {
if (!open) {
setEditingDatabase(null);
setEditPassword('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Database</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
updateMutation.mutate({
name: editName !== editingDatabase?.name ? editName : undefined,
password: editPassword.trim() || undefined,
});
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={editName}
onChange={(event) => setEditName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>New Password (Optional)</Label>
<Input
value={editPassword}
onChange={(event) => setEditPassword(event.target.value)}
minLength={8}
placeholder="Leave empty to keep the current password"
/>
<p className="text-xs text-muted-foreground">
Entering a value rotates the MySQL user password immediately.
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : databases.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
No databases yet. Create one for plugins, web panels, or server-side data.
</CardContent>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{databases.map((database) => (
<Card key={database.id}>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<CardTitle className="text-base">{database.name}</CardTitle>
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(database.createdAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2">
{database.phpMyAdminUrl ? (
<Button asChild size="sm" variant="outline">
<a href={database.phpMyAdminUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" /> phpMyAdmin
</a>
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={() => setEditingDatabase(database)}
>
<RefreshCw className="h-4 w-4" /> Edit
</Button>
<Button
size="sm"
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
const confirmed = window.confirm(
`Delete "${database.name}" and permanently drop ${database.databaseName}?`,
);
if (!confirmed) return;
deleteMutation.mutate(database.id);
}}
>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<InfoRow label="Host" value={database.host} />
<InfoRow label="Port" value={String(database.port)} />
<InfoRow label="Database" value={database.databaseName} />
<InfoRow label="Username" value={database.username} />
</div>
<InfoRow label="Password" value={database.password} />
<InfoRow
label="Connection URI"
value={`mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}`}
/>
{!database.phpMyAdminUrl ? (
<p className="text-xs text-muted-foreground">
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the daemon config for this node.
</p>
) : null}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+479 -67
View File
@@ -31,9 +31,30 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
interface PluginInstallField {
key: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select';
description?: string;
required?: boolean;
defaultValue?: unknown;
options?: Array<{ label: string; value: string }>;
min?: number;
max?: number;
pattern?: string;
secret?: boolean;
}
interface InstalledPluginRelease {
id: string;
version: string;
installSchema: PluginInstallField[];
}
interface InstalledPlugin {
id: string;
pluginId: string;
releaseId: string | null;
name: string;
slug: string;
description: string | null;
@@ -41,7 +62,17 @@ interface InstalledPlugin {
externalId: string | null;
installedVersion: string | null;
isActive: boolean;
installOptions: Record<string, unknown>;
autoUpdateChannel: 'stable' | 'beta' | 'alpha';
isPinned: boolean;
status: 'installed' | 'updating' | 'failed';
lastError: string | null;
updateAvailable: boolean;
latestReleaseId: string | null;
latestVersion: string | null;
latestChannel: 'stable' | 'beta' | 'alpha' | null;
installedAt: string;
currentRelease: InstalledPluginRelease | null;
}
interface SpigetResult {
@@ -68,7 +99,19 @@ interface MarketplacePlugin {
installId: string | null;
installedVersion: string | null;
isActive: boolean;
isPinned: boolean;
autoUpdateChannel: 'stable' | 'beta' | 'alpha';
installedAt: string | null;
releaseId: string | null;
updateAvailable: boolean;
latestRelease: {
id: string;
version: string;
channel: 'stable' | 'beta' | 'alpha';
artifactType: 'file' | 'zip';
artifactUrl: string;
installSchema: PluginInstallField[];
} | null;
}
interface MarketplaceResponse {
@@ -90,6 +133,91 @@ function extractApiMessage(error: unknown, fallback: string): string {
return fallback;
}
function buildInstallOptionsState(
fields: PluginInstallField[],
current: Record<string, unknown> = {},
): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const field of fields) {
if (field.defaultValue !== undefined) {
next[field.key] = field.defaultValue;
}
}
for (const [key, value] of Object.entries(current)) {
next[key] = value;
}
return next;
}
function PluginInstallSchemaFields({
fields,
values,
onChange,
}: {
fields: PluginInstallField[];
values: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
}) {
return (
<>
{fields.map((field) => (
<div className="space-y-2" key={field.key}>
<Label>{field.label}</Label>
{field.type === 'select' ? (
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={String(values[field.key] ?? '')}
onChange={(e) => onChange({ ...values, [field.key]: e.target.value })}
>
<option value="">Select...</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : field.type === 'boolean' ? (
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={Boolean(values[field.key])}
onChange={(e) => onChange({ ...values, [field.key]: e.target.checked })}
/>
Enabled
</label>
) : (
<Input
type={field.type === 'number' ? 'number' : field.secret ? 'password' : 'text'}
value={String(values[field.key] ?? '')}
onChange={(e) =>
onChange({
...values,
[field.key]:
field.type === 'number'
? e.target.value === ''
? ''
: Number(e.target.value)
: e.target.value,
})
}
min={field.type === 'number' ? field.min : undefined}
max={field.type === 'number' ? field.max : undefined}
pattern={field.type === 'text' ? field.pattern : undefined}
required={Boolean(field.required)}
/>
)}
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
</>
);
}
export function PluginsPage() {
const { orgId, serverId } = useParams();
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
@@ -161,6 +289,11 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
const [downloadUrl, setDownloadUrl] = useState('');
const [version, setVersion] = useState('');
const [description, setDescription] = useState('');
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [installTarget, setInstallTarget] = useState<MarketplacePlugin | null>(null);
const [installOptions, setInstallOptions] = useState<Record<string, unknown>>({});
const [installPinVersion, setInstallPinVersion] = useState(false);
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
const { data, isLoading } = useQuery({
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
@@ -172,10 +305,24 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
});
const installMutation = useMutation({
mutationFn: (pluginId: string) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`),
mutationFn: ({
pluginId,
payload,
}: {
pluginId: string;
payload?: {
releaseId?: string;
options?: Record<string, unknown>;
pinVersion?: boolean;
autoUpdateChannel?: 'stable' | 'beta' | 'alpha';
};
}) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`, payload ?? {}),
onSuccess: () => {
toast.success('Plugin installed');
setInstallDialogOpen(false);
setInstallTarget(null);
setInstallOptions({});
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
@@ -197,6 +344,19 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
},
});
const updateInstallMutation = useMutation({
mutationFn: ({ installId }: { installId: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}/update`),
onSuccess: () => {
toast.success('Plugin updated');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin update failed'));
},
});
const createMutation = useMutation({
mutationFn: (body: {
name: string;
@@ -278,6 +438,26 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
setEditOpen(true);
};
const openInstallDialog = (plugin: MarketplacePlugin) => {
const fields = plugin.latestRelease?.installSchema ?? [];
setInstallTarget(plugin);
setInstallOptions(buildInstallOptionsState(fields));
setInstallPinVersion(false);
setInstallAutoUpdateChannel('stable');
setInstallDialogOpen(true);
};
const installDirect = (plugin: MarketplacePlugin) => {
installMutation.mutate({
pluginId: plugin.id,
payload: {
releaseId: plugin.latestRelease?.id ?? undefined,
pinVersion: false,
autoUpdateChannel: 'stable',
},
});
};
const plugins = data?.plugins ?? [];
const gameName = data?.game.name ?? 'Game';
@@ -453,36 +633,67 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{plugin.name}</p>
<Badge variant="outline">{plugin.source}</Badge>
{plugin.version && <Badge variant="secondary">v{plugin.version}</Badge>}
{plugin.latestRelease?.version && (
<Badge variant="secondary">v{plugin.latestRelease.version}</Badge>
)}
{plugin.isInstalled && <Badge>Installed</Badge>}
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
{plugin.downloadUrl && (
<p className="line-clamp-1 text-xs text-muted-foreground">{plugin.downloadUrl}</p>
{plugin.latestRelease?.artifactUrl && (
<p className="line-clamp-1 text-xs text-muted-foreground">
{plugin.latestRelease.artifactUrl}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{plugin.isInstalled && plugin.installId ? (
<Button
variant="destructive"
size="sm"
onClick={() => uninstallMutation.mutate(plugin.installId!)}
disabled={uninstallMutation.isPending}
>
<Trash2 className="h-4 w-4" />
Kaldır
</Button>
<>
{plugin.updateAvailable && (
<Button
size="sm"
variant="secondary"
onClick={() => updateInstallMutation.mutate({ installId: plugin.installId! })}
disabled={updateInstallMutation.isPending}
>
<Download className="h-4 w-4" />
Güncelle
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => uninstallMutation.mutate(plugin.installId!)}
disabled={uninstallMutation.isPending}
>
<Trash2 className="h-4 w-4" />
Kaldır
</Button>
</>
) : (
<Button
size="sm"
onClick={() => installMutation.mutate(plugin.id)}
disabled={installMutation.isPending}
>
<Download className="h-4 w-4" />
Kur
</Button>
<>
{(plugin.latestRelease?.installSchema?.length ?? 0) > 0 ? (
<Button
size="sm"
onClick={() => openInstallDialog(plugin)}
disabled={installMutation.isPending}
>
<Download className="h-4 w-4" />
Ayarla ve Kur
</Button>
) : (
<Button
size="sm"
onClick={() => installDirect(plugin)}
disabled={installMutation.isPending}
>
<Download className="h-4 w-4" />
Kur
</Button>
)}
</>
)}
<Button
@@ -508,6 +719,70 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
</Card>
))}
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Install Plugin
{installTarget ? ` - ${installTarget.name}` : ''}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!installTarget) return;
installMutation.mutate({
pluginId: installTarget.id,
payload: {
releaseId: installTarget.latestRelease?.id ?? undefined,
options: installOptions,
pinVersion: installPinVersion,
autoUpdateChannel: installAutoUpdateChannel,
},
});
}}
>
<PluginInstallSchemaFields
fields={installTarget?.latestRelease?.installSchema ?? []}
values={installOptions}
onChange={setInstallOptions}
/>
<div className="space-y-2">
<Label>Auto Update Channel</Label>
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={installAutoUpdateChannel}
onChange={(e) =>
setInstallAutoUpdateChannel(e.target.value as 'stable' | 'beta' | 'alpha')
}
>
<option value="stable">stable</option>
<option value="beta">beta</option>
<option value="alpha">alpha</option>
</select>
</div>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={installPinVersion}
onChange={(e) => setInstallPinVersion(e.target.checked)}
/>
Pin this release version
</label>
<DialogFooter>
<Button type="submit" disabled={installMutation.isPending || !installTarget}>
{installMutation.isPending ? 'Installing...' : 'Install'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
@@ -522,6 +797,9 @@ function InstalledPlugins({
serverId: string;
}) {
const queryClient = useQueryClient();
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
const [configureTarget, setConfigureTarget] = useState<InstalledPlugin | null>(null);
const [configureOptions, setConfigureOptions] = useState<Record<string, unknown>>({});
const toggleMutation = useMutation({
mutationFn: (id: string) =>
@@ -545,6 +823,50 @@ function InstalledPlugins({
},
});
const updateMutation = useMutation({
mutationFn: (id: string) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/update`),
onSuccess: () => {
toast.success('Plugin güncellendi');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin güncellenemedi'));
},
});
const configureMutation = useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: {
releaseId: string;
options: Record<string, unknown>;
};
}) => api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/update`, payload),
onSuccess: () => {
toast.success('Plugin ayarları güncellendi');
setConfigureDialogOpen(false);
setConfigureTarget(null);
setConfigureOptions({});
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin ayarları güncellenemedi'));
},
});
const openConfigureDialog = (plugin: InstalledPlugin) => {
const fields = plugin.currentRelease?.installSchema ?? [];
setConfigureTarget(plugin);
setConfigureOptions(buildInstallOptionsState(fields, plugin.installOptions));
setConfigureDialogOpen(true);
};
if (installed.length === 0) {
return (
<Card>
@@ -560,48 +882,138 @@ function InstalledPlugins({
}
return (
<div className="space-y-2">
{installed.map((plugin) => (
<Card key={plugin.id}>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Puzzle className="h-5 w-5 text-primary" />
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{plugin.name}</p>
<Badge variant="outline">{plugin.source}</Badge>
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
<>
<div className="space-y-2">
{installed.map((plugin) => (
<Card key={plugin.id}>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<Puzzle className="h-5 w-5 text-primary" />
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{plugin.name}</p>
<Badge variant="outline">{plugin.source}</Badge>
{plugin.installedVersion && <Badge variant="secondary">v{plugin.installedVersion}</Badge>}
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
{plugin.status !== 'installed' && (
<Badge variant={plugin.status === 'failed' ? 'destructive' : 'outline'}>
{plugin.status}
</Badge>
)}
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
{plugin.latestVersion && (
<p className="text-xs text-muted-foreground">
Latest: v{plugin.latestVersion}
{plugin.latestChannel ? ` (${plugin.latestChannel})` : ''}
</p>
)}
{plugin.lastError && (
<p className="text-xs text-destructive">{plugin.lastError}</p>
)}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
onClick={() => toggleMutation.mutate(plugin.id)}
title={plugin.isActive ? 'Disable' : 'Enable'}
>
{plugin.isActive ? (
<ToggleRight className="h-4 w-4 text-green-500" />
) : (
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2">
{plugin.currentRelease && plugin.currentRelease.installSchema.length > 0 && (
<Button
size="icon"
variant="ghost"
onClick={() => openConfigureDialog(plugin)}
title="Ayarları düzenle"
disabled={configureMutation.isPending}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</Button>
{plugin.updateAvailable && (
<Button
size="icon"
variant="ghost"
onClick={() => updateMutation.mutate(plugin.id)}
title="Update"
disabled={updateMutation.isPending}
>
<Download className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => toggleMutation.mutate(plugin.id)}
title={plugin.isActive ? 'Disable' : 'Enable'}
>
{plugin.isActive ? (
<ToggleRight className="h-4 w-4 text-green-500" />
) : (
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
)}
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => uninstallMutation.mutate(plugin.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<Dialog
open={configureDialogOpen}
onOpenChange={(open) => {
setConfigureDialogOpen(open);
if (!open) {
setConfigureTarget(null);
setConfigureOptions({});
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Plugin Ayarları
{configureTarget ? ` - ${configureTarget.name}` : ''}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!configureTarget?.currentRelease) return;
configureMutation.mutate({
id: configureTarget.id,
payload: {
releaseId: configureTarget.currentRelease.id,
options: configureOptions,
},
});
}}
>
<PluginInstallSchemaFields
fields={configureTarget?.currentRelease?.installSchema ?? []}
values={configureOptions}
onChange={setConfigureOptions}
/>
<DialogFooter>
<Button
size="icon"
variant="ghost"
onClick={() => uninstallMutation.mutate(plugin.id)}
type="submit"
disabled={configureMutation.isPending || !configureTarget?.currentRelease}
>
<Trash2 className="h-4 w-4 text-destructive" />
{configureMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
@@ -696,18 +1108,18 @@ function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string })
function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string }) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [fileName, setFileName] = useState('');
const [filePath, setFilePath] = useState('');
const [version, setVersion] = useState('');
const installMutation = useMutation({
mutationFn: (body: { name: string; fileName: string; version?: string }) =>
mutationFn: (body: { name: string; filePath: string; version?: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/manual`, body),
onSuccess: () => {
toast.success('Plugin registered');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
setName('');
setFileName('');
setFilePath('');
setVersion('');
},
onError: (error) => {
@@ -727,7 +1139,7 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
e.preventDefault();
installMutation.mutate({
name,
fileName,
filePath,
version: version || undefined,
});
}}
@@ -737,15 +1149,15 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>File Name</Label>
<Label>File Path</Label>
<Input
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="plugin.jar"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="plugins/plugin.jar"
required
/>
<p className="text-xs text-muted-foreground">
Upload the file to the correct plugin directory via Files tab first.
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun varsayılan plugin dizinine göre çözülür.
</p>
</div>
<div className="space-y-2">
+364 -5
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useOutletContext, useParams } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { Button } from '@/components/ui/button';
@@ -12,15 +13,50 @@ import { formatBytes } from '@/lib/utils';
interface ServerDetail {
id: string;
gameId: string;
name: string;
description?: string;
memoryLimit: number;
diskLimit: number;
cpuLimit: number;
startupOverride?: string;
startupOverride?: string | null;
environment?: Record<string, string>;
}
interface GameEnvironmentVar {
key: string;
label?: string;
default?: string;
description?: string;
required?: boolean;
inputType?: 'text' | 'boolean';
composeInto?: string;
flagValue?: string;
enabledLabel?: string;
disabledLabel?: string;
}
interface GameDefinition {
id: string;
startupCommand: string;
environmentVars?: GameEnvironmentVar[];
}
interface EnvironmentField {
key: string;
label: string;
value: string;
defaultValue: string;
description: string;
required: boolean;
inputType: 'text' | 'boolean';
composeInto?: string;
flagValue?: string;
enabledLabel?: string;
disabledLabel?: string;
isCustom: boolean;
}
type AutomationEvent =
| 'server.created'
| 'server.install.completed'
@@ -65,23 +101,182 @@ function extractApiMessage(error: unknown, fallback: string): string {
return fallback;
}
function normalizeStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const normalized: Record<string, string> = {};
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
const normalizedKey = key.trim();
if (!normalizedKey) continue;
normalized[normalizedKey] = String(entryValue ?? '');
}
return normalized;
}
function buildEnvironmentFields(
game: GameDefinition | undefined,
serverEnvironment: unknown,
): EnvironmentField[] {
const overrides = normalizeStringRecord(serverEnvironment);
const fields: EnvironmentField[] = [];
const knownKeys = new Set<string>();
for (const variable of game?.environmentVars ?? []) {
const key = variable.key?.trim();
if (!key) continue;
const composeInto = variable.composeInto?.trim();
const flagValue = variable.flagValue?.trim();
if (!composeInto) {
knownKeys.add(key);
}
if (composeInto && flagValue) {
const baseValue = overrides[composeInto] ?? '';
const tokens = baseValue.trim() ? baseValue.trim().split(/\s+/) : [];
fields.push({
key,
label: variable.label?.trim() || key,
value: tokens.includes(flagValue) ? 'true' : 'false',
defaultValue: 'false',
description: variable.description ?? '',
required: Boolean(variable.required),
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
composeInto,
flagValue,
enabledLabel: variable.enabledLabel,
disabledLabel: variable.disabledLabel,
isCustom: false,
});
continue;
}
fields.push({
key,
label: variable.label?.trim() || key,
value: overrides[key] ?? String(variable.default ?? ''),
defaultValue: String(variable.default ?? ''),
description: variable.description ?? '',
required: Boolean(variable.required),
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
composeInto,
flagValue,
enabledLabel: variable.enabledLabel,
disabledLabel: variable.disabledLabel,
isCustom: false,
});
}
for (const [key, value] of Object.entries(overrides)) {
if (knownKeys.has(key)) continue;
fields.push({
key,
label: key,
value,
defaultValue: '',
description: '',
required: false,
inputType: 'text',
isCustom: true,
});
}
return fields;
}
function buildEnvironmentPayload(fields: EnvironmentField[]): Record<string, string> {
const payload: Record<string, string> = {};
const defaults = new Map<string, string>();
for (const field of fields) {
const key = field.key.trim();
if (!key) continue;
if (!field.isCustom) {
defaults.set(key, field.defaultValue);
}
if (field.isCustom) {
payload[key] = field.value;
continue;
}
if (field.composeInto) continue;
if (field.value !== field.defaultValue) {
payload[key] = field.value;
}
}
for (const field of fields) {
if (field.isCustom || !field.composeInto || !field.flagValue) continue;
const targetKey = field.composeInto.trim();
if (!targetKey) continue;
const defaultValue = defaults.get(targetKey) ?? '';
const currentValue = payload[targetKey] ?? defaultValue;
const tokens = currentValue.trim() ? currentValue.trim().split(/\s+/) : [];
const nextTokens = tokens.filter((token) => token !== field.flagValue);
if (field.value === 'true') {
nextTokens.push(field.flagValue);
}
const nextValue = nextTokens.join(' ').trim();
if (!nextValue || nextValue === defaultValue) {
delete payload[targetKey];
continue;
}
payload[targetKey] = nextValue;
}
return payload;
}
export function ServerSettingsPage() {
const { orgId, serverId } = useParams();
const navigate = useNavigate();
const { server } = useOutletContext<{ server?: ServerDetail }>();
const queryClient = useQueryClient();
const [name, setName] = useState(server?.name ?? '');
const [description, setDescription] = useState(server?.description ?? '');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [startupOverride, setStartupOverride] = useState('');
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
const [forceAutomationRun, setForceAutomationRun] = useState(false);
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
const { data: gamesData } = useQuery({
queryKey: ['games'],
queryFn: () => api.get<{ data: GameDefinition[] }>('/games'),
});
const activeGame = (gamesData?.data ?? []).find((game) => game.id === server?.gameId);
const serverEnvironmentJson = JSON.stringify(server?.environment ?? {});
useEffect(() => {
if (!server) return;
setName(server.name);
setDescription(server.description ?? '');
}, [server?.id, server?.name, server?.description]);
useEffect(() => {
if (!server) return;
setStartupOverride(server.startupOverride ?? '');
setEnvironmentFields(buildEnvironmentFields(activeGame, server.environment));
}, [server?.id, server?.startupOverride, serverEnvironmentJson, activeGame]);
const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.patch(`/organizations/${orgId}/servers/${serverId}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
toast.success('Server settings saved');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to save server settings'));
},
});
@@ -117,6 +312,42 @@ export function ServerSettingsPage() {
},
});
const updateEnvironmentField = (
index: number,
patch: Partial<Pick<EnvironmentField, 'key' | 'value'>>,
) => {
setEnvironmentFields((prev) =>
prev.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
);
};
const addCustomEnvironmentField = () => {
setEnvironmentFields((prev) => [
...prev,
{
key: '',
label: '',
value: '',
defaultValue: '',
description: '',
required: false,
inputType: 'text',
isCustom: true,
},
]);
};
const removeEnvironmentField = (index: number) => {
setEnvironmentFields((prev) => prev.filter((_, fieldIndex) => fieldIndex !== index));
};
const saveStartupSettings = () => {
updateMutation.mutate({
startupOverride: startupOverride.trim(),
environment: buildEnvironmentPayload(environmentFields),
});
};
return (
<div className="space-y-6">
<Card>
@@ -169,6 +400,134 @@ export function ServerSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Startup</CardTitle>
<CardDescription>
Saving these values recreates the container with the same files and restarts it if it
was running.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Startup Override</Label>
<Input
value={startupOverride}
onChange={(e) => setStartupOverride(e.target.value)}
placeholder={activeGame?.startupCommand || 'Use image default command'}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the game default startup command or the image entrypoint.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Environment Variables</Label>
<p className="text-xs text-muted-foreground">
Add custom keys for image-specific startup switches such as extra launch args.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addCustomEnvironmentField}>
<Plus className="h-4 w-4" />
Add Variable
</Button>
</div>
{environmentFields.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
This game does not define any startup variables yet.
</p>
) : (
<div className="space-y-3">
{environmentFields.map((field, index) => (
field.isCustom ? (
<div
key={`custom-${index}`}
className="grid gap-2 rounded-md border p-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
>
<Input
value={field.key}
onChange={(e) => updateEnvironmentField(index, { key: e.target.value })}
placeholder="ENV_KEY"
className="font-mono text-sm"
/>
<Input
value={field.value}
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
placeholder="value"
className="font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEnvironmentField(index)}
aria-label="Remove environment variable"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<div key={field.key} className="grid gap-1.5 rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<Label className="font-mono text-xs text-muted-foreground">
{field.label}
</Label>
<span className="text-[11px] text-muted-foreground">
Default: <span className="font-mono">{field.defaultValue || 'empty'}</span>
</span>
</div>
{field.inputType === 'boolean' ? (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={field.value === 'true' ? 'default' : 'outline'}
size="sm"
onClick={() => updateEnvironmentField(index, { value: 'true' })}
>
{field.enabledLabel ?? 'Enabled'}
</Button>
<Button
type="button"
variant={field.value === 'false' ? 'secondary' : 'outline'}
size="sm"
onClick={() => updateEnvironmentField(index, { value: 'false' })}
>
{field.disabledLabel ?? 'Disabled'}
</Button>
</div>
) : (
<Input
value={field.value}
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
className="font-mono text-sm"
/>
)}
{(field.description || field.required) && (
<p className="text-xs text-muted-foreground">
{field.description || 'Required startup variable'}
{field.required ? ' Required.' : ''}
</p>
)}
</div>
)
))}
</div>
)}
</div>
<Button
onClick={saveStartupSettings}
disabled={updateMutation.isPending || !server}
>
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Automation</CardTitle>