feat: overhaul server automation, files editor, and CS2 setup workflows

This commit is contained in:
2026-02-26 21:01:00 +00:00
parent 44c439e2f9
commit 2a3ad5e78f
40 changed files with 4675 additions and 468 deletions
+16 -3
View File
@@ -1,4 +1,9 @@
const API_BASE = '/api';
const RAW_API_BASE = (
(import.meta.env.VITE_API_URL as string | undefined) ??
(import.meta.env.VITE_API_BASE_URL as string | undefined) ??
'/api'
).trim();
const API_BASE = (RAW_API_BASE || '/api').replace(/\/+$/, '');
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
@@ -36,7 +41,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, { ...fetchOptions, headers });
const res = await fetch(url, {
...fetchOptions,
credentials: fetchOptions.credentials ?? 'include',
headers,
});
const shouldHandle401WithRefresh =
res.status === 401 &&
@@ -49,7 +58,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
const retry = await fetch(url, { ...fetchOptions, headers });
const retry = await fetch(url, {
...fetchOptions,
credentials: fetchOptions.credentials ?? 'include',
headers,
});
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
if (retry.status === 204) return undefined as T;
return retry.json();
+159 -6
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Gamepad2 } from 'lucide-react';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { api, ApiError } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -23,16 +24,55 @@ interface Game {
dockerImage: string;
defaultPort: number;
startupCommand: string;
automationRules: unknown[];
}
interface PaginatedResponse<T> {
data: T[];
meta: { total: number };
interface GamesResponse {
data: Game[];
}
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 formatAutomationRules(value: unknown): string {
if (!Array.isArray(value)) {
return '[]';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return '[]';
}
}
function parseAutomationRules(raw: string): { rules: unknown[]; error: string | null } {
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return { rules: [], error: 'Automation JSON must be an array.' };
}
return { rules: parsed, error: null };
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
return { rules: [], error: message };
}
}
export function AdminGamesPage() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [automationOpen, setAutomationOpen] = useState(false);
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
const [automationJson, setAutomationJson] = useState('[]');
const [automationError, setAutomationError] = useState<string | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [dockerImage, setDockerImage] = useState('');
@@ -41,7 +81,7 @@ export function AdminGamesPage() {
const { data } = useQuery({
queryKey: ['admin-games'],
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
queryFn: () => api.get<GamesResponse>('/admin/games'),
});
const createMutation = useMutation({
@@ -53,11 +93,70 @@ export function AdminGamesPage() {
setSlug('');
setDockerImage('');
setStartupCommand('');
toast.success('Game created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create game'));
},
});
const updateAutomationMutation = useMutation({
mutationFn: ({ gameId, rules }: { gameId: string; rules: unknown[] }) =>
api.patch(`/admin/games/${gameId}`, { automationRules: rules }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-games'] });
setAutomationOpen(false);
setSelectedGame(null);
setAutomationError(null);
toast.success('Automation rules updated');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to save automation rules'));
},
});
const games = data?.data ?? [];
const openAutomationDialog = (game: Game) => {
setSelectedGame(game);
setAutomationJson(formatAutomationRules(game.automationRules));
setAutomationError(null);
setAutomationOpen(true);
};
const saveAutomationRules = () => {
if (!selectedGame) return;
const parsed = parseAutomationRules(automationJson);
if (parsed.error) {
setAutomationError(parsed.error);
return;
}
setAutomationError(null);
updateAutomationMutation.mutate({
gameId: selectedGame.id,
rules: parsed.rules,
});
};
const handleAutomationTabKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Tab') return;
event.preventDefault();
const textarea = event.currentTarget;
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
const nextValue = `${automationJson.slice(0, selectionStart)} ${automationJson.slice(selectionEnd)}`;
const nextCursor = selectionStart + 2;
setAutomationJson(nextValue);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(nextCursor, nextCursor);
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -142,11 +241,65 @@ export function AdminGamesPage() {
</p>
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
<p>Port: {game.defaultPort}</p>
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
</div>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => openAutomationDialog(game)}
>
Manage Automation
</Button>
</CardContent>
</Card>
))}
</div>
<Dialog
open={automationOpen}
onOpenChange={(nextOpen) => {
setAutomationOpen(nextOpen);
if (!nextOpen) {
setSelectedGame(null);
setAutomationError(null);
}
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
Automation Rules
{selectedGame ? ` - ${selectedGame.name}` : ''}
</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>JSON</Label>
<p className="text-xs text-muted-foreground">
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
</p>
<textarea
value={automationJson}
onChange={(event) => setAutomationJson(event.target.value)}
onKeyDown={handleAutomationTabKey}
spellCheck={false}
className="min-h-[320px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{automationError && <p className="text-sm text-destructive">{automationError}</p>}
</div>
<DialogFooter>
<Button
type="button"
onClick={saveAutomationRules}
disabled={updateAutomationMutation.isPending || !selectedGame}
>
{updateAutomationMutation.isPending ? 'Saving...' : 'Save Automation'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2 -2
View File
@@ -142,10 +142,10 @@ export function NodeDetailPage() {
);
}
const memPercent = stats
const memPercent = stats && stats.memoryTotal > 0
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
: 0;
const diskPercent = stats
const diskPercent = stats && stats.diskTotal > 0
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
: 0;
+5 -1
View File
@@ -133,6 +133,10 @@ function ConfigEditor({
? 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">
@@ -149,7 +153,7 @@ function ConfigEditor({
</div>
<Button
size="sm"
onClick={() => saveMutation.mutate({ entries })}
onClick={() => saveMutation.mutate({ entries: entriesToSave })}
disabled={saveMutation.isPending}
>
<Save className="h-4 w-4" />
File diff suppressed because it is too large Load Diff
+458 -20
View File
@@ -1,23 +1,27 @@
import { useState } from 'react';
import { useParams } from 'react-router';
import { useOutletContext, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Download,
Puzzle,
Search,
Download,
Trash2,
Star,
ToggleLeft,
ToggleRight,
Star,
Trash2,
Upload,
Store,
Plus,
Pencil,
} from 'lucide-react';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { api, ApiError } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
@@ -50,9 +54,46 @@ interface SpigetResult {
external: boolean;
}
interface MarketplacePlugin {
id: string;
name: string;
slug: string;
description: string | null;
source: 'spiget' | 'manual';
externalId: string | null;
downloadUrl: string | null;
version: string | null;
updatedAt: string;
isInstalled: boolean;
installId: string | null;
installedVersion: string | null;
isActive: boolean;
installedAt: string | null;
}
interface MarketplaceResponse {
game: {
id: string;
slug: string;
name: string;
};
plugins: MarketplacePlugin[];
}
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;
}
export function PluginsPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
const isMinecraft = server?.gameSlug === 'minecraft-java';
const { data: pluginsData } = useQuery({
queryKey: ['plugins', orgId, serverId],
@@ -65,29 +106,41 @@ export function PluginsPage() {
const installed = pluginsData?.plugins ?? [];
return (
<Tabs defaultValue="installed" className="space-y-4">
<TabsList>
<Tabs defaultValue="marketplace" className="space-y-4">
<TabsList className="flex h-auto w-full flex-wrap">
<TabsTrigger value="marketplace">
<Store className="mr-1.5 h-3.5 w-3.5" />
Marketplace
</TabsTrigger>
<TabsTrigger value="installed">
<Puzzle className="mr-1.5 h-3.5 w-3.5" />
Installed ({installed.length})
</TabsTrigger>
<TabsTrigger value="search">
<Search className="mr-1.5 h-3.5 w-3.5" />
Search Plugins
</TabsTrigger>
{isMinecraft && (
<TabsTrigger value="search">
<Search className="mr-1.5 h-3.5 w-3.5" />
Spiget Search
</TabsTrigger>
)}
<TabsTrigger value="manual">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Manual Install
</TabsTrigger>
</TabsList>
<TabsContent value="marketplace">
<MarketplacePlugins orgId={orgId!} serverId={serverId!} />
</TabsContent>
<TabsContent value="installed">
<InstalledPlugins installed={installed} orgId={orgId!} serverId={serverId!} />
</TabsContent>
<TabsContent value="search">
<SpigetSearch orgId={orgId!} serverId={serverId!} />
</TabsContent>
{isMinecraft && (
<TabsContent value="search">
<SpigetSearch orgId={orgId!} serverId={serverId!} />
</TabsContent>
)}
<TabsContent value="manual">
<ManualInstall orgId={orgId!} serverId={serverId!} />
@@ -96,6 +149,369 @@ export function PluginsPage() {
);
}
function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: string }) {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editingPluginId, setEditingPluginId] = useState<string | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [downloadUrl, setDownloadUrl] = useState('');
const [version, setVersion] = useState('');
const [description, setDescription] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
queryFn: () =>
api.get<MarketplaceResponse>(
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`,
searchTerm ? { q: searchTerm } : undefined,
),
});
const installMutation = useMutation({
mutationFn: (pluginId: string) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`),
onSuccess: () => {
toast.success('Plugin installed');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin install failed'));
},
});
const uninstallMutation = useMutation({
mutationFn: (installId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}`),
onSuccess: () => {
toast.success('Plugin uninstalled');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin uninstall failed'));
},
});
const createMutation = useMutation({
mutationFn: (body: {
name: string;
slug?: string;
description?: string;
downloadUrl: string;
version?: string;
}) =>
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
onSuccess: () => {
toast.success('Marketplace plugin added');
setCreateOpen(false);
setName('');
setSlug('');
setDownloadUrl('');
setVersion('');
setDescription('');
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to add marketplace plugin'));
},
});
const deleteMutation = useMutation({
mutationFn: (pluginId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${pluginId}`),
onSuccess: () => {
toast.success('Marketplace plugin removed');
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to remove marketplace plugin'));
},
});
const updateMutation = useMutation({
mutationFn: (body: {
pluginId: string;
name: string;
slug?: string;
description?: string;
downloadUrl?: string;
version?: string;
}) =>
api.patch(
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${body.pluginId}`,
{
name: body.name,
slug: body.slug,
description: body.description,
downloadUrl: body.downloadUrl,
version: body.version,
},
),
onSuccess: () => {
toast.success('Marketplace plugin updated');
setEditOpen(false);
setEditingPluginId(null);
setName('');
setSlug('');
setDownloadUrl('');
setVersion('');
setDescription('');
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update marketplace plugin'));
},
});
const openEditDialog = (plugin: MarketplacePlugin) => {
setEditingPluginId(plugin.id);
setName(plugin.name);
setSlug(plugin.slug);
setDownloadUrl(plugin.downloadUrl ?? '');
setVersion(plugin.version ?? '');
setDescription(plugin.description ?? '');
setEditOpen(true);
};
const plugins = data?.plugins ?? [];
const gameName = data?.game.name ?? 'Game';
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-semibold">{gameName} Marketplace</h3>
<p className="text-sm text-muted-foreground">
Oyununuza uygun eklentileri tek tıkla kur/kaldırın.
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
Plugin Ekle
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Marketplace Plugin Ekle</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate({
name,
slug: slug || undefined,
description: description || undefined,
downloadUrl,
version: version || undefined,
});
}}
>
<div className="space-y-2">
<Label>Plugin Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Slug (optional)</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Download URL</Label>
<Input
type="url"
value={downloadUrl}
onChange={(e) => setDownloadUrl(e.target.value)}
placeholder="https://example.com/plugin.jar"
required
/>
</div>
<div className="space-y-2">
<Label>Version (optional)</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Ekleniyor...' : 'Ekle'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={editOpen}
onOpenChange={(open) => {
setEditOpen(open);
if (!open) {
setEditingPluginId(null);
setName('');
setSlug('');
setDownloadUrl('');
setVersion('');
setDescription('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Marketplace Plugin Düzenle</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!editingPluginId) return;
updateMutation.mutate({
pluginId: editingPluginId,
name,
slug: slug || undefined,
description: description || undefined,
downloadUrl: downloadUrl || undefined,
version: version || undefined,
});
}}
>
<div className="space-y-2">
<Label>Plugin Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<Label>Slug (optional)</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Download URL (optional)</Label>
<Input
type="url"
value={downloadUrl}
onChange={(e) => setDownloadUrl(e.target.value)}
placeholder="https://example.com/plugin.jar"
/>
</div>
<div className="space-y-2">
<Label>Version (optional)</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="flex gap-2">
<Input
placeholder="Plugin ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') setSearchTerm(search.trim());
}}
/>
<Button onClick={() => setSearchTerm(search.trim())}>
<Search className="h-4 w-4" />
Ara
</Button>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Marketplace yükleniyor...</p>}
{!isLoading && plugins.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<Store className="mb-3 h-10 w-10 text-muted-foreground/60" />
<p className="font-medium">Bu oyun için plugin bulunamadı</p>
<p className="text-sm text-muted-foreground">
Yetkili kullanıcılar yukarıdan marketplace plugin ekleyebilir.
</p>
</CardContent>
</Card>
)}
<div className="space-y-2">
{plugins.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="space-y-1">
<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.isInstalled && <Badge>Installed</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>
)}
</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>
) : (
<Button
size="sm"
onClick={() => installMutation.mutate(plugin.id)}
disabled={installMutation.isPending}
>
<Download className="h-4 w-4" />
Kur
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={() => openEditDialog(plugin)}
title="Marketplace kaydını düzenle"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => deleteMutation.mutate(plugin.id)}
disabled={deleteMutation.isPending || plugin.isInstalled}
title={plugin.isInstalled ? 'Önce sunucudan kaldırın' : 'Marketplace kaydını sil'}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
function InstalledPlugins({
installed,
orgId,
@@ -111,12 +527,22 @@ function InstalledPlugins({
mutationFn: (id: string) =>
api.patch(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/toggle`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin durumu güncellenemedi'));
},
});
const uninstallMutation = useMutation({
mutationFn: (id: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
onSuccess: () => {
toast.success('Plugin kaldırıldı');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin kaldırılamadı'));
},
});
if (installed.length === 0) {
@@ -126,7 +552,7 @@ function InstalledPlugins({
<Puzzle className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">No plugins installed</p>
<p className="mt-1 text-xs text-muted-foreground">
Search for plugins or install manually
Marketplace sekmesinden tek tıkla kurulum yapabilirsiniz.
</p>
</CardContent>
</Card>
@@ -199,7 +625,14 @@ function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string })
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, {
resourceId,
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
onSuccess: () => {
toast.success('Spiget plugin installed');
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Spiget install failed'));
},
});
const handleSearch = () => {
@@ -270,11 +703,16 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
mutationFn: (body: { name: string; fileName: 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('');
setVersion('');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Plugin registration failed'));
},
});
return (
@@ -307,7 +745,7 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
required
/>
<p className="text-xs text-muted-foreground">
Upload the file to /plugins/ directory via the Files tab first
Upload the file to the correct plugin directory via Files tab first.
</p>
</div>
<div className="space-y-2">
+197 -3
View File
@@ -1,11 +1,13 @@
import { useState } from 'react';
import { useParams, useOutletContext } from 'react-router';
import { useNavigate, useOutletContext, useParams } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { formatBytes } from '@/lib/utils';
interface ServerDetail {
@@ -19,13 +21,61 @@ interface ServerDetail {
environment?: Record<string, string>;
}
type AutomationEvent =
| 'server.created'
| 'server.install.completed'
| 'server.power.started'
| 'server.power.stopped';
interface AutomationRunResult {
workflowsMatched: number;
workflowsExecuted: number;
workflowsSkipped: number;
workflowsFailed: number;
actionFailures: number;
failures: Array<{
level: 'action' | 'workflow';
workflowId: string;
actionId?: string;
message: string;
}>;
}
interface AutomationRunResponse {
success: boolean;
event: AutomationEvent;
force: boolean;
result: AutomationRunResult;
}
const AUTOMATION_EVENTS: AutomationEvent[] = [
'server.created',
'server.install.completed',
'server.power.started',
'server.power.stopped',
];
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;
}
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 [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
const [forceAutomationRun, setForceAutomationRun] = useState(false);
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
@@ -35,6 +85,38 @@ export function ServerSettingsPage() {
},
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/organizations/${orgId}/servers/${serverId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
navigate(`/org/${orgId}/servers`);
},
});
const automationRunMutation = useMutation({
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
api.post<AutomationRunResponse>(`/organizations/${orgId}/servers/${serverId}/automation/run`, body),
onSuccess: (response) => {
setLastAutomationResult(response.result);
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
const firstFailure = response.result.failures[0]?.message;
toast.error(
firstFailure
? `Automation failed: ${firstFailure}`
: `Automation completed with errors (${response.result.workflowsFailed} workflow failures)`,
);
return;
}
toast.success(
`Automation completed: ${response.result.workflowsExecuted} workflows executed`,
);
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to run automation'));
},
});
return (
<div className="space-y-6">
<Card>
@@ -87,13 +169,125 @@ export function ServerSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Automation</CardTitle>
<CardDescription>Manually trigger an automation event for this server</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Event</Label>
<Select value={automationEvent} onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTOMATION_EVENTS.map((eventName) => (
<SelectItem key={eventName} value={eventName}>
{eventName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={forceAutomationRun ? 'default' : 'outline'}
onClick={() => setForceAutomationRun((prev) => !prev)}
>
{forceAutomationRun ? 'Force: ON' : 'Force: OFF'}
</Button>
<Button
type="button"
onClick={() => automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })}
disabled={automationRunMutation.isPending}
>
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enabling force will rerun workflows that are marked runOncePerServer.
</p>
{lastAutomationResult && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Matched</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsMatched}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Executed</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsExecuted}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Skipped</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsSkipped}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Failed</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsFailed}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Action Failures</p>
<p className="text-lg font-semibold">{lastAutomationResult.actionFailures}</p>
</div>
</div>
{lastAutomationResult.failures.length > 0 && (
<div className="space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">Failure Details</p>
<div className="space-y-1">
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
<p key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`} className="text-xs text-destructive">
[{failure.workflowId}{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
</p>
))}
</div>
</div>
)}
</div>
)}
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length === 0 && (
<p className="text-xs text-green-600">Automation run completed successfully.</p>
)}
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && (
<p className="text-xs text-destructive">
Automation run completed with {lastAutomationResult.failures.length} error(s).
</p>
)}
{automationRunMutation.isError && (
<p className="text-xs text-destructive">
Failed to run automation event.
</p>
)}
</CardContent>
</Card>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>Irreversible actions</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive">Delete Server</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (!window.confirm('Delete this server permanently? This action cannot be undone.')) {
return;
}
deleteMutation.mutate();
}}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete Server'}
</Button>
</CardContent>
</Card>
</div>
+2 -2
View File
@@ -51,8 +51,8 @@ export function CreateServerPage() {
const [cpuLimit, setCpuLimit] = useState(100);
const { data: gamesData } = useQuery({
queryKey: ['admin-games'],
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
queryKey: ['games'],
queryFn: () => api.get<PaginatedResponse<Game>>('/games'),
});
const { data: nodesData } = useQuery({
+68 -2
View File
@@ -24,6 +24,32 @@ interface Member {
username: string;
email: string;
role: 'admin' | 'user';
customPermissions: Record<string, boolean>;
}
type MembershipPreset = 'admin' | 'moderator' | 'user';
const MODERATOR_PERMISSIONS: Record<string, boolean> = {
'plugin.manage': true,
};
function getMemberPreset(member: Member): MembershipPreset {
if (member.role === 'admin') return 'admin';
if (member.customPermissions?.['plugin.manage']) return 'moderator';
return 'user';
}
function buildPresetPayload(preset: MembershipPreset): {
role: 'admin' | 'user';
customPermissions: Record<string, boolean>;
} {
if (preset === 'admin') {
return { role: 'admin', customPermissions: {} };
}
if (preset === 'moderator') {
return { role: 'user', customPermissions: MODERATOR_PERMISSIONS };
}
return { role: 'user', customPermissions: {} };
}
export function MembersPage() {
@@ -32,6 +58,7 @@ export function MembersPage() {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState('');
const [role, setRole] = useState<'admin' | 'user'>('user');
const [updatingMemberId, setUpdatingMemberId] = useState<string | null>(null);
const { data: membersData } = useQuery({
queryKey: ['members', orgId],
@@ -58,6 +85,26 @@ export function MembersPage() {
},
});
const updateMutation = useMutation({
mutationFn: ({
memberId,
preset,
}: {
memberId: string;
preset: MembershipPreset;
}) =>
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
onMutate: ({ memberId }) => {
setUpdatingMemberId(memberId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
},
onSettled: () => {
setUpdatingMemberId(null);
},
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -123,9 +170,28 @@ export function MembersPage() {
<p className="text-sm text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={member.role === 'admin' ? 'default' : 'secondary'}>
{member.role}
<Badge variant={getMemberPreset(member) === 'admin' ? 'default' : 'secondary'}>
{getMemberPreset(member)}
</Badge>
<Select
value={getMemberPreset(member)}
onValueChange={(value) =>
updateMutation.mutate({
memberId: member.id,
preset: value as MembershipPreset,
})
}
disabled={updateMutation.isPending && updatingMemberId === member.id}
>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"