fix: resolve frontend routing, API mismatches, and missing UI components
- Add servers list page and missing routes (servers, settings redirect, account security)
- Fix members page .map error (API returns { data } wrapper, not flat array)
- Fix auth store fetchUser expecting flat User but API returns { user } wrapper
- Add node token display dialog after creation
- Add allocation management UI to node detail page
- Add account security page with password change
- Add change-password API endpoint
- Add node servers and stats API endpoints
- Fix config save using PATCH instead of PUT, add api.put method
- Fix audit logs field name mismatch (userName vs username)
- Replace admin nodes page to avoid orgId dependency
- Remove duplicate sidebar nav items
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d7d8fd5339
commit
c9fe2bd9fe
|
|
@ -171,6 +171,39 @@ export default async function authRoutes(app: FastifyInstance) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/change-password
|
||||||
|
app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => {
|
||||||
|
const { currentPassword, newPassword } = request.body as {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||||
|
throw AppError.badRequest('New password must be at least 8 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await app.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, request.user.sub),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw AppError.notFound('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||||
|
if (!isValid) {
|
||||||
|
throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHash = await hashPassword(newPassword);
|
||||||
|
await app.db
|
||||||
|
.update(users)
|
||||||
|
.set({ passwordHash: newHash, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/auth/me
|
// GET /api/auth/me
|
||||||
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
||||||
const payload = request.user;
|
const payload = request.user;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { nodes, allocations } from '@source/database';
|
import { nodes, allocations, servers, games } from '@source/database';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
|
|
@ -133,6 +133,58 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/organizations/:orgId/nodes/:nodeId/servers
|
||||||
|
app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => {
|
||||||
|
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||||
|
await requirePermission(request, orgId, 'node.read');
|
||||||
|
|
||||||
|
const serverList = await app.db
|
||||||
|
.select({
|
||||||
|
id: servers.id,
|
||||||
|
name: servers.name,
|
||||||
|
status: servers.status,
|
||||||
|
memoryLimit: servers.memoryLimit,
|
||||||
|
cpuLimit: servers.cpuLimit,
|
||||||
|
gameName: games.name,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.leftJoin(games, eq(servers.gameId, games.id))
|
||||||
|
.where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId)));
|
||||||
|
|
||||||
|
return { data: serverList };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
||||||
|
// Returns basic stats from DB; real-time stats come from daemon via gRPC
|
||||||
|
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
||||||
|
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||||
|
await requirePermission(request, orgId, 'node.read');
|
||||||
|
|
||||||
|
const node = await app.db.query.nodes.findFirst({
|
||||||
|
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
|
||||||
|
});
|
||||||
|
if (!node) throw AppError.notFound('Node not found');
|
||||||
|
|
||||||
|
const serverList = await app.db
|
||||||
|
.select({ id: servers.id, status: servers.status })
|
||||||
|
.from(servers)
|
||||||
|
.where(eq(servers.nodeId, nodeId));
|
||||||
|
|
||||||
|
const totalServers = serverList.length;
|
||||||
|
const activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuPercent: 0,
|
||||||
|
memoryUsed: 0,
|
||||||
|
memoryTotal: node.memoryTotal,
|
||||||
|
diskUsed: 0,
|
||||||
|
diskTotal: node.diskTotal,
|
||||||
|
activeServers,
|
||||||
|
totalServers,
|
||||||
|
uptime: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// === Allocations ===
|
// === Allocations ===
|
||||||
|
|
||||||
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
|
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { RegisterPage } from '@/pages/auth/register';
|
||||||
// App pages
|
// App pages
|
||||||
import { OrganizationsPage } from '@/pages/organizations/index';
|
import { OrganizationsPage } from '@/pages/organizations/index';
|
||||||
import { DashboardPage } from '@/pages/dashboard/index';
|
import { DashboardPage } from '@/pages/dashboard/index';
|
||||||
|
import { ServersPage } from '@/pages/servers/index';
|
||||||
import { CreateServerPage } from '@/pages/servers/create';
|
import { CreateServerPage } from '@/pages/servers/create';
|
||||||
import { NodesPage } from '@/pages/nodes/index';
|
import { NodesPage } from '@/pages/nodes/index';
|
||||||
import { NodeDetailPage } from '@/pages/nodes/detail';
|
import { NodeDetailPage } from '@/pages/nodes/detail';
|
||||||
|
|
@ -35,7 +36,9 @@ import { ServerSettingsPage } from '@/pages/server/settings';
|
||||||
// Admin pages
|
// Admin pages
|
||||||
import { AdminUsersPage } from '@/pages/admin/users';
|
import { AdminUsersPage } from '@/pages/admin/users';
|
||||||
import { AdminGamesPage } from '@/pages/admin/games';
|
import { AdminGamesPage } from '@/pages/admin/games';
|
||||||
|
import { AdminNodesPage } from '@/pages/admin/nodes';
|
||||||
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
||||||
|
import { AccountSecurityPage } from '@/pages/account/security';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -87,11 +90,16 @@ export function App() {
|
||||||
|
|
||||||
{/* Org-scoped routes */}
|
{/* Org-scoped routes */}
|
||||||
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
||||||
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
||||||
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
||||||
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
||||||
|
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
||||||
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<Route path="/account/security" element={<AccountSecurityPage />} />
|
||||||
|
|
||||||
{/* Server detail */}
|
{/* Server detail */}
|
||||||
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
||||||
<Route index element={<Navigate to="console" replace />} />
|
<Route index element={<Navigate to="console" replace />} />
|
||||||
|
|
@ -108,7 +116,7 @@ export function App() {
|
||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||||
<Route path="/admin/nodes" element={<NodesPage />} />
|
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,7 @@ export function Sidebar() {
|
||||||
{ label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard },
|
{ label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard },
|
||||||
{ label: 'Servers', href: `/org/${orgId}/servers`, icon: Server },
|
{ label: 'Servers', href: `/org/${orgId}/servers`, icon: Server },
|
||||||
{ label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network },
|
{ label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network },
|
||||||
{ label: 'Members', href: `/org/${orgId}/settings/members`, icon: Users },
|
{ label: 'Settings', href: `/org/${orgId}/settings/members`, icon: Settings },
|
||||||
{ label: 'Settings', href: `/org/${orgId}/settings`, icon: Settings },
|
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ export const api = {
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
patch: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, {
|
request<T>(path, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Shield, Key } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
export function AccountSecurityPage() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
const changePasswordMutation = useMutation({
|
||||||
|
mutationFn: (body: { currentPassword: string; newPassword: string }) =>
|
||||||
|
api.post('/auth/change-password', body),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Password changed successfully');
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to change password. Check your current password.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChangePassword = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error('New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
toast.error('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePasswordMutation.mutate({ currentPassword, newPassword });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Account Settings</h1>
|
||||||
|
<p className="text-muted-foreground">Manage your account security</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>Your account information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Username</span>
|
||||||
|
<span className="font-medium">{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Email</span>
|
||||||
|
<span className="font-medium">{user?.email}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Role</span>
|
||||||
|
<span className="font-medium">{user?.isSuperAdmin ? 'Super Admin' : 'User'}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Key className="h-5 w-5" />
|
||||||
|
<div>
|
||||||
|
<CardTitle>Change Password</CardTitle>
|
||||||
|
<CardDescription>Update your account password</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="currentPassword">Current Password</Label>
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={changePasswordMutation.isPending}>
|
||||||
|
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
||||||
interface AuditLog {
|
interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
action: string;
|
action: string;
|
||||||
username: string;
|
userName: string;
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -36,7 +36,7 @@ export function AdminAuditLogsPage() {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">{log.action}</Badge>
|
<Badge variant="outline">{log.action}</Badge>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<span className="font-medium">{log.username}</span>
|
<span className="font-medium">{log.userName}</span>
|
||||||
{log.ipAddress && (
|
{log.ipAddress && (
|
||||||
<span className="text-muted-foreground"> from {log.ipAddress}</span>
|
<span className="text-muted-foreground"> from {log.ipAddress}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Network, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { formatBytes } from '@/lib/utils';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface NodeItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fqdn: string;
|
||||||
|
daemonPort: number;
|
||||||
|
grpcPort: number;
|
||||||
|
memoryTotal: number;
|
||||||
|
diskTotal: number;
|
||||||
|
isOnline: boolean;
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminNodesPage() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['admin-nodes'],
|
||||||
|
queryFn: () => api.get<{ data: NodeItem[] }>('/admin/nodes'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">All Nodes</h1>
|
||||||
|
<p className="text-muted-foreground">{nodes.length} nodes across all organizations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<Card key={node.id}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Network className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||||
|
{node.isOnline ? (
|
||||||
|
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||||
|
) : (
|
||||||
|
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
||||||
|
<div className="mt-3 flex gap-4 text-sm">
|
||||||
|
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||||
|
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
No nodes registered
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Network,
|
Network,
|
||||||
|
|
@ -9,14 +10,26 @@ import {
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Server,
|
Server,
|
||||||
Activity,
|
Plus,
|
||||||
|
Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface NodeDetail {
|
interface NodeDetail {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -51,8 +64,20 @@ interface ServerSummary {
|
||||||
gameName: string;
|
gameName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Allocation {
|
||||||
|
id: string;
|
||||||
|
nodeId: string;
|
||||||
|
serverId: string | null;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function NodeDetailPage() {
|
export function NodeDetailPage() {
|
||||||
const { orgId, nodeId } = useParams();
|
const { orgId, nodeId } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [allocOpen, setAllocOpen] = useState(false);
|
||||||
|
const [allocIp, setAllocIp] = useState('0.0.0.0');
|
||||||
|
const [allocPorts, setAllocPorts] = useState('');
|
||||||
|
|
||||||
const { data: node } = useQuery({
|
const { data: node } = useQuery({
|
||||||
queryKey: ['node', orgId, nodeId],
|
queryKey: ['node', orgId, nodeId],
|
||||||
|
|
@ -73,6 +98,40 @@ export function NodeDetailPage() {
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allocData } = useQuery({
|
||||||
|
queryKey: ['allocations', orgId, nodeId],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ data: Allocation[] }>(
|
||||||
|
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allocations = allocData?.data ?? [];
|
||||||
|
|
||||||
|
const createAllocMutation = useMutation({
|
||||||
|
mutationFn: (body: { ip: string; ports: number[] }) =>
|
||||||
|
api.post(`/organizations/${orgId}/nodes/${nodeId}/allocations`, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['allocations', orgId, nodeId] });
|
||||||
|
setAllocOpen(false);
|
||||||
|
setAllocPorts('');
|
||||||
|
toast.success('Allocations created');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to create allocations');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddAllocations = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const ports = parsePorts(allocPorts);
|
||||||
|
if (ports.length === 0) {
|
||||||
|
toast.error('Enter valid ports (e.g. 25565, 25566-25570)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createAllocMutation.mutate({ ip: allocIp, ports });
|
||||||
|
};
|
||||||
|
|
||||||
const servers = serversData?.data ?? [];
|
const servers = serversData?.data ?? [];
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
|
|
@ -234,10 +293,102 @@ export function NodeDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Allocations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
<CardTitle>Allocations</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Dialog open={allocOpen} onOpenChange={setAllocOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4" /> Add Ports
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Allocations</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleAddAllocations} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>IP Address</Label>
|
||||||
|
<Input
|
||||||
|
value={allocIp}
|
||||||
|
onChange={(e) => setAllocIp(e.target.value)}
|
||||||
|
placeholder="0.0.0.0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Ports</Label>
|
||||||
|
<Input
|
||||||
|
value={allocPorts}
|
||||||
|
onChange={(e) => setAllocPorts(e.target.value)}
|
||||||
|
placeholder="25565, 25566-25570"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Comma-separated ports or ranges (e.g. 25565, 25566-25570)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={createAllocMutation.isPending}>
|
||||||
|
{createAllocMutation.isPending ? 'Adding...' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{allocations.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No allocations yet. Add ports to assign to servers.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{allocations.map((alloc) => (
|
||||||
|
<div
|
||||||
|
key={alloc.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="font-mono text-sm">
|
||||||
|
{alloc.ip}:{alloc.port}
|
||||||
|
</div>
|
||||||
|
<Badge variant={alloc.serverId ? 'default' : 'outline'}>
|
||||||
|
{alloc.serverId ? 'In use' : 'Available'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
|
||||||
|
function parsePorts(input: string): number[] {
|
||||||
|
const ports: number[] = [];
|
||||||
|
const parts = input.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes('-')) {
|
||||||
|
const [startStr, endStr] = part.split('-');
|
||||||
|
const start = parseInt(startStr!, 10);
|
||||||
|
const end = parseInt(endStr!, 10);
|
||||||
|
if (isNaN(start) || isNaN(end) || start > end || start < 1 || end > 65535) continue;
|
||||||
|
for (let p = start; p <= end; p++) ports.push(p);
|
||||||
|
} else {
|
||||||
|
const p = parseInt(part, 10);
|
||||||
|
if (!isNaN(p) && p >= 1 && p <= 65535) ports.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Network, Wifi, WifiOff } from 'lucide-react';
|
import { Plus, Network, Wifi, WifiOff, Copy, Check } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface NodeItem {
|
interface NodeItem {
|
||||||
|
|
@ -29,6 +31,10 @@ interface NodeItem {
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreatedNode extends NodeItem {
|
||||||
|
daemonToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
interface PaginatedResponse<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
meta: { total: number };
|
meta: { total: number };
|
||||||
|
|
@ -38,6 +44,9 @@ export function NodesPage() {
|
||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [tokenDialog, setTokenDialog] = useState(false);
|
||||||
|
const [createdToken, setCreatedToken] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [fqdn, setFqdn] = useState('');
|
const [fqdn, setFqdn] = useState('');
|
||||||
const [daemonPort, setDaemonPort] = useState(8443);
|
const [daemonPort, setDaemonPort] = useState(8443);
|
||||||
|
|
@ -52,17 +61,28 @@ export function NodesPage() {
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
api.post(`/organizations/${orgId}/nodes`, body),
|
api.post<CreatedNode>(`/organizations/${orgId}/nodes`, body),
|
||||||
onSuccess: () => {
|
onSuccess: (node) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['nodes', orgId] });
|
queryClient.invalidateQueries({ queryKey: ['nodes', orgId] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setName('');
|
setName('');
|
||||||
setFqdn('');
|
setFqdn('');
|
||||||
|
// Show token dialog
|
||||||
|
setCreatedToken(node.daemonToken);
|
||||||
|
setTokenDialog(true);
|
||||||
|
setCopied(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodes = data?.data ?? [];
|
const nodes = data?.data ?? [];
|
||||||
|
|
||||||
|
const handleCopyToken = async () => {
|
||||||
|
await navigator.clipboard.writeText(createdToken);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success('Token copied to clipboard');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -151,6 +171,45 @@ export function NodesPage() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Token display dialog */}
|
||||||
|
<Dialog open={tokenDialog} onOpenChange={setTokenDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Node Created Successfully</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Save this daemon token now. It will not be shown again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Daemon Token</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={createdToken}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use this token in your daemon configuration file (config.yml) to authenticate with the panel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setTokenDialog(false)}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ function ConfigEditor({
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
||||||
api.patch(
|
api.put(
|
||||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
||||||
data,
|
data,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useParams, Link } from 'react-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Server, Plus } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { statusBadgeVariant } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ServerSummary {
|
||||||
|
id: string;
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
gameName: string;
|
||||||
|
nodeName: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: { total: number; page: number; perPage: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServersPage() {
|
||||||
|
const { orgId } = useParams();
|
||||||
|
|
||||||
|
const { data: serversData } = useQuery({
|
||||||
|
queryKey: ['servers', orgId],
|
||||||
|
queryFn: () => api.get<PaginatedResponse<ServerSummary>>(`/organizations/${orgId}/servers`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const servers = serversData?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Servers</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{servers.length} server{servers.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to={`/org/${orgId}/servers/new`}>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Server
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Server className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="text-muted-foreground">No servers yet</p>
|
||||||
|
<Link to={`/org/${orgId}/servers/new`}>
|
||||||
|
<Button variant="outline" className="mt-4">
|
||||||
|
Create your first server
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<Link key={server.id} to={`/org/${orgId}/servers/${server.id}/console`}>
|
||||||
|
<Card className="transition-colors hover:border-primary/50">
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Server className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{server.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{server.gameName} · {server.nodeName} · :{server.port}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,11 +33,13 @@ export function MembersPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||||
|
|
||||||
const { data: members } = useQuery({
|
const { data: membersData } = useQuery({
|
||||||
queryKey: ['members', orgId],
|
queryKey: ['members', orgId],
|
||||||
queryFn: () => api.get<Member[]>(`/organizations/${orgId}/members`),
|
queryFn: () => api.get<{ data: Member[] }>(`/organizations/${orgId}/members`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const members = membersData?.data ?? [];
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (body: { email: string; role: string }) =>
|
mutationFn: (body: { email: string; role: string }) =>
|
||||||
api.post(`/organizations/${orgId}/members`, body),
|
api.post(`/organizations/${orgId}/members`, body),
|
||||||
|
|
@ -114,7 +116,7 @@ export function MembersPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{(members ?? []).map((member) => (
|
{members.map((member) => (
|
||||||
<div key={member.id} className="flex items-center justify-between px-4 py-3">
|
<div key={member.id} className="flex items-center justify-between px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{member.username}</p>
|
<p className="font-medium">{member.username}</p>
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
|
||||||
fetchUser: async () => {
|
fetchUser: async () => {
|
||||||
try {
|
try {
|
||||||
const user = await api.get<User>('/auth/me');
|
const data = await api.get<{ user: User }>('/auth/me');
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user: data.user, isAuthenticated: true, isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 401) {
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue