Add panel feature updates across API, daemon, and web
This commit is contained in:
@@ -31,11 +31,13 @@ import { SchedulesPage } from '@/pages/server/schedules';
|
||||
import { ConfigPage } from '@/pages/server/config';
|
||||
import { PluginsPage } from '@/pages/server/plugins';
|
||||
import { PlayersPage } from '@/pages/server/players';
|
||||
import { DatabasesPage } from '@/pages/server/databases';
|
||||
import { ServerSettingsPage } from '@/pages/server/settings';
|
||||
|
||||
// Admin pages
|
||||
import { AdminUsersPage } from '@/pages/admin/users';
|
||||
import { AdminGamesPage } from '@/pages/admin/games';
|
||||
import { AdminPluginsPage } from '@/pages/admin/plugins';
|
||||
import { AdminNodesPage } from '@/pages/admin/nodes';
|
||||
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
||||
import { AccountSecurityPage } from '@/pages/account/security';
|
||||
@@ -106,6 +108,7 @@ export function App() {
|
||||
<Route path="console" element={<ConsolePage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="databases" element={<DatabasesPage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="schedules" element={<SchedulesPage />} />
|
||||
@@ -116,6 +119,7 @@ export function App() {
|
||||
{/* Admin */}
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2 } from 'lucide-react';
|
||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react';
|
||||
import { cn } from '@source/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -26,6 +26,7 @@ const tabs = [
|
||||
{ label: 'Console', path: 'console', icon: Terminal },
|
||||
{ label: 'Files', path: 'files', icon: FolderOpen },
|
||||
{ label: 'Config', path: 'config', icon: Settings2 },
|
||||
{ label: 'Databases', path: 'databases', icon: DatabaseIcon },
|
||||
{ label: 'Plugins', path: 'plugins', icon: Puzzle },
|
||||
{ label: 'Backups', path: 'backups', icon: HardDrive },
|
||||
{ label: 'Schedules', path: 'schedules', icon: Calendar },
|
||||
@@ -40,6 +41,7 @@ export function ServerLayout() {
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ['server', orgId, serverId],
|
||||
queryFn: () => api.get<ServerDetail>(`/organizations/${orgId}/servers/${serverId}`),
|
||||
refetchInterval: 3_000,
|
||||
});
|
||||
|
||||
const currentTab = location.pathname.split('/').pop();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Users,
|
||||
Shield,
|
||||
Gamepad2,
|
||||
Puzzle,
|
||||
ScrollText,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
@@ -40,6 +41,7 @@ export function Sidebar() {
|
||||
? [
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Games', href: '/admin/games', icon: Gamepad2 },
|
||||
{ label: 'Plugins', href: '/admin/plugins', icon: Puzzle },
|
||||
{ label: 'Nodes', href: '/admin/nodes', icon: Network },
|
||||
{ label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText },
|
||||
]
|
||||
|
||||
@@ -19,14 +19,38 @@ interface PowerControlsProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
interface CachedServerDetail {
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const serverQueryKey = ['server', orgId, serverId] as const;
|
||||
|
||||
const powerMutation = useMutation({
|
||||
mutationFn: (action: string) =>
|
||||
mutationFn: (action: PowerAction) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
|
||||
onMutate: (action) => {
|
||||
const nextStatusByAction: Record<PowerAction, string> = {
|
||||
start: 'starting',
|
||||
stop: 'stopping',
|
||||
restart: 'stopping',
|
||||
kill: 'stopped',
|
||||
};
|
||||
|
||||
queryClient.setQueryData<CachedServerDetail | undefined>(serverQueryKey, (current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
status: nextStatusByAction[action],
|
||||
};
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serverQueryKey });
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,23 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, hsl(var(--primary) / 0.18), transparent 34%),
|
||||
radial-gradient(circle at 88% 10%, hsl(var(--ring) / 0.12), transparent 28%),
|
||||
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.72) 100%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -9,6 +9,21 @@ interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
function toRequestBody(body: unknown): BodyInit | undefined {
|
||||
if (body === undefined || body === null) return undefined;
|
||||
|
||||
if (
|
||||
body instanceof FormData ||
|
||||
body instanceof Blob ||
|
||||
body instanceof URLSearchParams ||
|
||||
body instanceof ArrayBuffer
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
@@ -102,19 +117,19 @@ export const api = {
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, UploadCloud, Puzzle, Rocket, Copy } from 'lucide-react';
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GamesResponse {
|
||||
data: Game[];
|
||||
}
|
||||
|
||||
interface GlobalPlugin {
|
||||
id: string;
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
gameSlug: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
source: 'manual' | 'spiget';
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
interface GlobalPluginsResponse {
|
||||
data: GlobalPlugin[];
|
||||
}
|
||||
|
||||
interface PluginRelease {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
channel: 'stable' | 'beta' | 'alpha';
|
||||
artifactType: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination: string | null;
|
||||
fileName: string | null;
|
||||
changelog: string | null;
|
||||
installSchema: unknown[];
|
||||
configTemplates: unknown[];
|
||||
isPublished: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PluginReleaseResponse {
|
||||
plugin: GlobalPlugin;
|
||||
releases: PluginRelease[];
|
||||
}
|
||||
|
||||
type ReleaseInputMode = 'url' | 'upload';
|
||||
|
||||
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 prettyJson(input: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(raw: string): unknown[] {
|
||||
if (raw.trim() === '') return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON value must be an array');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function parseJsonArrayFile(file: File, label: string): Promise<unknown[]> {
|
||||
let raw = await file.text();
|
||||
if (raw.charCodeAt(0) === 0xfeff) {
|
||||
raw = raw.slice(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON value must be an array');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
throw new Error(`${label}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminPluginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedGameId, setSelectedGameId] = useState<string>('');
|
||||
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
|
||||
|
||||
const [createPluginOpen, setCreatePluginOpen] = useState(false);
|
||||
const [createPluginName, setCreatePluginName] = useState('');
|
||||
const [createPluginSlug, setCreatePluginSlug] = useState('');
|
||||
const [createPluginDescription, setCreatePluginDescription] = useState('');
|
||||
|
||||
const [createReleaseOpen, setCreateReleaseOpen] = useState(false);
|
||||
const [releaseInputMode, setReleaseInputMode] = useState<ReleaseInputMode>('upload');
|
||||
const [releaseVersion, setReleaseVersion] = useState('');
|
||||
const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||
const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file');
|
||||
const [releaseArtifactUrl, setReleaseArtifactUrl] = useState('');
|
||||
const [releaseDestination, setReleaseDestination] = useState('');
|
||||
const [releaseFileName, setReleaseFileName] = useState('');
|
||||
const [releaseChangelog, setReleaseChangelog] = useState('');
|
||||
const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]');
|
||||
const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]');
|
||||
const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState<File | null>(null);
|
||||
const [releaseTemplatesFile, setReleaseTemplatesFile] = useState<File | null>(null);
|
||||
const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0);
|
||||
const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0);
|
||||
const [releaseArtifactFiles, setReleaseArtifactFiles] = useState<File[]>([]);
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||
});
|
||||
|
||||
const games = gamesData?.data ?? [];
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryKey: ['admin-plugins', selectedGameId],
|
||||
queryFn: () =>
|
||||
api.get<GlobalPluginsResponse>(
|
||||
'/admin/plugins',
|
||||
selectedGameId ? { gameId: selectedGameId } : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const plugins = pluginsData?.data ?? [];
|
||||
|
||||
const selectedPlugin = useMemo(
|
||||
() => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null,
|
||||
[plugins, selectedPluginId],
|
||||
);
|
||||
|
||||
const { data: releaseData } = useQuery({
|
||||
queryKey: ['admin-plugin-releases', selectedPluginId],
|
||||
enabled: Boolean(selectedPluginId),
|
||||
queryFn: () => api.get<PluginReleaseResponse>(`/admin/plugins/${selectedPluginId}/releases`),
|
||||
});
|
||||
|
||||
const releases = releaseData?.releases ?? [];
|
||||
|
||||
const resetReleaseForm = () => {
|
||||
setCreateReleaseOpen(false);
|
||||
setReleaseInputMode('upload');
|
||||
setReleaseVersion('');
|
||||
setReleaseChannel('stable');
|
||||
setReleaseArtifactType('file');
|
||||
setReleaseArtifactUrl('');
|
||||
setReleaseDestination('');
|
||||
setReleaseFileName('');
|
||||
setReleaseChangelog('');
|
||||
setReleaseInstallSchemaJson('[]');
|
||||
setReleaseTemplatesJson('[]');
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
setReleaseArtifactFiles([]);
|
||||
};
|
||||
|
||||
const appendReleaseFiles = (incoming: FileList | null) => {
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
|
||||
setReleaseArtifactFiles((prev) => {
|
||||
const map = new Map<string, File>();
|
||||
|
||||
for (const item of prev) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
for (const item of Array.from(incoming)) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
const createPluginMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
gameId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
}) => api.post('/admin/plugins', body),
|
||||
onSuccess: () => {
|
||||
toast.success('Global plugin created');
|
||||
setCreatePluginOpen(false);
|
||||
setCreatePluginName('');
|
||||
setCreatePluginSlug('');
|
||||
setCreatePluginDescription('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create plugin'));
|
||||
},
|
||||
});
|
||||
|
||||
const createReleaseMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
version: string;
|
||||
channel: 'stable' | 'beta' | 'alpha';
|
||||
artifactType: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
}) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.post(`/admin/plugins/${selectedPluginId}/releases`, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Release published');
|
||||
resetReleaseForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to publish release'));
|
||||
},
|
||||
});
|
||||
|
||||
const createUploadReleaseMutation = useMutation({
|
||||
mutationFn: (formData: FormData) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Release uploaded and published');
|
||||
resetReleaseForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to upload release'));
|
||||
},
|
||||
});
|
||||
|
||||
const togglePublishedMutation = useMutation({
|
||||
mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to update release'));
|
||||
},
|
||||
});
|
||||
|
||||
const openReleaseDialogFrom = (release?: PluginRelease) => {
|
||||
setReleaseVersion('');
|
||||
setReleaseChannel('stable');
|
||||
setReleaseArtifactType('file');
|
||||
setReleaseArtifactUrl('');
|
||||
setReleaseDestination('');
|
||||
setReleaseFileName('');
|
||||
setReleaseChangelog('');
|
||||
setReleaseInstallSchemaJson('[]');
|
||||
setReleaseTemplatesJson('[]');
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
setReleaseArtifactFiles([]);
|
||||
setReleaseInputMode(release ? 'url' : 'upload');
|
||||
|
||||
if (release) {
|
||||
setReleaseChannel(release.channel);
|
||||
setReleaseArtifactType(release.artifactType);
|
||||
setReleaseArtifactUrl(release.artifactUrl);
|
||||
setReleaseDestination(release.destination ?? '');
|
||||
setReleaseFileName(release.fileName ?? '');
|
||||
setReleaseChangelog(release.changelog ?? '');
|
||||
setReleaseInstallSchemaJson(prettyJson(release.installSchema));
|
||||
setReleaseTemplatesJson(prettyJson(release.configTemplates));
|
||||
}
|
||||
setCreateReleaseOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Global Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createPluginOpen} onOpenChange={setCreatePluginOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> Add Global Plugin
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Global Plugin</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedGameId) {
|
||||
toast.error('Select a game first');
|
||||
return;
|
||||
}
|
||||
createPluginMutation.mutate({
|
||||
gameId: selectedGameId,
|
||||
name: createPluginName,
|
||||
slug: createPluginSlug || undefined,
|
||||
description: createPluginDescription || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Game</Label>
|
||||
<Input
|
||||
value={games.find((game) => game.id === selectedGameId)?.name ?? ''}
|
||||
readOnly
|
||||
placeholder="Select game from filter above"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional)</Label>
|
||||
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createPluginMutation.isPending}>
|
||||
{createPluginMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="min-w-20">Game Filter</Label>
|
||||
<select
|
||||
className="h-10 rounded-md border bg-background px-3 text-sm"
|
||||
value={selectedGameId}
|
||||
onChange={(e) => {
|
||||
setSelectedGameId(e.target.value);
|
||||
setSelectedPluginId(null);
|
||||
}}
|
||||
>
|
||||
<option value="">All Games</option>
|
||||
{games.map((game) => (
|
||||
<option key={game.id} value={game.id}>
|
||||
{game.name} ({game.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plugins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{plugins.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No plugins found for this filter.</p>
|
||||
)}
|
||||
{plugins.map((plugin) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPluginId(plugin.id)}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left transition ${
|
||||
selectedPluginId === plugin.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">{plugin.name}</span>
|
||||
<Badge variant="outline">{plugin.gameSlug}</Badge>
|
||||
<Badge variant="secondary">{plugin.source}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plugin.slug}</p>
|
||||
{plugin.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Releases</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openReleaseDialogFrom(releases[0])}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<Copy className="h-4 w-4" /> Clone Latest
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openReleaseDialogFrom()}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4" /> New Release
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{!selectedPlugin && (
|
||||
<p className="text-sm text-muted-foreground">Select a plugin to manage releases.</p>
|
||||
)}
|
||||
{selectedPlugin && releases.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No releases published yet.</p>
|
||||
)}
|
||||
{releases.map((release) => (
|
||||
<div key={release.id} className="rounded-md border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">v{release.version}</span>
|
||||
<Badge variant="outline">{release.channel}</Badge>
|
||||
<Badge variant="secondary">{release.artifactType}</Badge>
|
||||
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '}
|
||||
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
togglePublishedMutation.mutate({
|
||||
releaseId: release.id,
|
||||
isPublished: !release.isPublished,
|
||||
})
|
||||
}
|
||||
disabled={togglePublishedMutation.isPending}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{release.isPublished ? 'Unpublish' : 'Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createReleaseOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setCreateReleaseOpen(true);
|
||||
return;
|
||||
}
|
||||
resetReleaseForm();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const installSchema = releaseInstallSchemaFile
|
||||
? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file')
|
||||
: parseJsonArray(releaseInstallSchemaJson);
|
||||
const configTemplates = releaseTemplatesFile
|
||||
? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file')
|
||||
: parseJsonArray(releaseTemplatesJson);
|
||||
|
||||
if (releaseInputMode === 'upload') {
|
||||
if (releaseArtifactFiles.length === 0) {
|
||||
toast.error('Select at least one file or folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('version', releaseVersion);
|
||||
formData.append('channel', releaseChannel);
|
||||
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
|
||||
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
||||
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
||||
if (releaseInstallSchemaFile) {
|
||||
formData.append(
|
||||
'installSchemaFile',
|
||||
releaseInstallSchemaFile,
|
||||
releaseInstallSchemaFile.name,
|
||||
);
|
||||
} else {
|
||||
formData.append('installSchema', JSON.stringify(installSchema));
|
||||
}
|
||||
if (releaseTemplatesFile) {
|
||||
formData.append(
|
||||
'configTemplatesFile',
|
||||
releaseTemplatesFile,
|
||||
releaseTemplatesFile.name,
|
||||
);
|
||||
} else {
|
||||
formData.append('configTemplates', JSON.stringify(configTemplates));
|
||||
}
|
||||
|
||||
for (const file of releaseArtifactFiles) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
|
||||
formData.append('files', file, file.name);
|
||||
}
|
||||
|
||||
createUploadReleaseMutation.mutate(formData);
|
||||
return;
|
||||
}
|
||||
|
||||
createReleaseMutation.mutate({
|
||||
version: releaseVersion,
|
||||
channel: releaseChannel,
|
||||
artifactType: releaseArtifactType,
|
||||
artifactUrl: releaseArtifactUrl,
|
||||
destination: releaseDestination || undefined,
|
||||
fileName: releaseFileName || undefined,
|
||||
changelog: releaseChangelog || undefined,
|
||||
installSchema,
|
||||
configTemplates,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
toast.error(`Release JSON error: ${message}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Version</Label>
|
||||
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={releaseChannel}
|
||||
onChange={(e) => setReleaseChannel(e.target.value as 'stable' | 'beta' | 'alpha')}
|
||||
>
|
||||
<option value="stable">stable</option>
|
||||
<option value="beta">beta</option>
|
||||
<option value="alpha">alpha</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Release Source</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={releaseInputMode === 'upload' ? 'default' : 'outline'}
|
||||
onClick={() => setReleaseInputMode('upload')}
|
||||
>
|
||||
CDN Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={releaseInputMode === 'url' ? 'default' : 'outline'}
|
||||
onClick={() => setReleaseInputMode('url')}
|
||||
>
|
||||
URL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{releaseInputMode === 'url' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Artifact Type</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={releaseArtifactType}
|
||||
onChange={(e) => setReleaseArtifactType(e.target.value as 'file' | 'zip')}
|
||||
>
|
||||
<option value="file">file</option>
|
||||
<option value="zip">zip</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Artifact URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={releaseArtifactUrl}
|
||||
onChange={(e) => setReleaseArtifactUrl(e.target.value)}
|
||||
required={releaseInputMode === 'url'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseInputMode === 'upload' && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
|
||||
yapilip CDN'e yuklenir.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Files</Label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="block w-full text-sm"
|
||||
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Folder</Label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
{...({ webkitdirectory: '', directory: '' } as Record<string, string>)}
|
||||
className="block w-full text-sm"
|
||||
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
||||
{releaseArtifactFiles.map((file, index) => {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
return (
|
||||
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
||||
{relativePath || file.name}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Destination (optional)</Label>
|
||||
<Input
|
||||
value={releaseDestination}
|
||||
onChange={(e) => setReleaseDestination(e.target.value)}
|
||||
placeholder="/game/csgo/addons"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>File Name (optional)</Label>
|
||||
<Input
|
||||
value={releaseFileName}
|
||||
onChange={(e) => setReleaseFileName(e.target.value)}
|
||||
placeholder="plugin.dll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Changelog (optional)</Label>
|
||||
<textarea
|
||||
value={releaseChangelog}
|
||||
onChange={(e) => setReleaseChangelog(e.target.value)}
|
||||
className="min-h-[90px] w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Install Schema JSON (array)</Label>
|
||||
<input
|
||||
key={releaseInstallSchemaFileInputKey}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="block w-full text-xs"
|
||||
onChange={(e) => setReleaseInstallSchemaFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{releaseInstallSchemaFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={releaseInstallSchemaJson}
|
||||
onChange={(e) => setReleaseInstallSchemaJson(e.target.value)}
|
||||
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Config Templates JSON (array)</Label>
|
||||
<input
|
||||
key={releaseTemplatesFileInputKey}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="block w-full text-xs"
|
||||
onChange={(e) => setReleaseTemplatesFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{releaseTemplatesFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={releaseTemplatesJson}
|
||||
onChange={(e) => setReleaseTemplatesJson(e.target.value)}
|
||||
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
createReleaseMutation.isPending ||
|
||||
createUploadReleaseMutation.isPending ||
|
||||
!selectedPlugin
|
||||
}
|
||||
>
|
||||
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
|
||||
? 'Publishing...'
|
||||
: 'Publish Release'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
||||
|
||||
@@ -36,7 +36,7 @@ export function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user