From 0941a9ba467cf1bf17e03f2387a4194b4f9ba92e Mon Sep 17 00:00:00 2001 From: hibna Date: Sat, 21 Feb 2026 16:59:21 +0300 Subject: [PATCH] chore: initial commit for phase05 --- apps/web/package.json | 22 +- apps/web/src/App.tsx | 117 +- apps/web/src/components/layout/app-layout.tsx | 17 + apps/web/src/components/layout/header.tsx | 56 + .../src/components/layout/server-layout.tsx | 89 ++ apps/web/src/components/layout/sidebar.tsx | 115 ++ .../src/components/server/power-controls.tsx | 102 ++ apps/web/src/components/ui/badge.tsx | 28 + apps/web/src/components/ui/button.tsx | 46 + apps/web/src/components/ui/card.tsx | 44 + apps/web/src/components/ui/dialog.tsx | 90 ++ apps/web/src/components/ui/dropdown-menu.tsx | 71 + apps/web/src/components/ui/input.tsx | 21 + apps/web/src/components/ui/label.tsx | 20 + apps/web/src/components/ui/progress.tsx | 22 + apps/web/src/components/ui/scroll-area.tsx | 24 + apps/web/src/components/ui/select.tsx | 88 ++ apps/web/src/components/ui/separator.tsx | 23 + apps/web/src/components/ui/tabs.tsx | 52 + apps/web/src/components/ui/tooltip.tsx | 27 + apps/web/src/hooks/use-theme.ts | 25 + apps/web/src/lib/api.ts | 99 ++ apps/web/src/lib/socket.ts | 30 + apps/web/src/lib/utils.ts | 51 + apps/web/src/pages/admin/audit-logs.tsx | 58 + apps/web/src/pages/admin/games.tsx | 152 +++ apps/web/src/pages/admin/users.tsx | 64 + apps/web/src/pages/auth/login.tsx | 89 ++ apps/web/src/pages/auth/register.tsx | 102 ++ apps/web/src/pages/dashboard/index.tsx | 125 ++ apps/web/src/pages/nodes/index.tsx | 182 +++ apps/web/src/pages/organizations/index.tsx | 129 ++ apps/web/src/pages/server/backups.tsx | 17 + apps/web/src/pages/server/console.tsx | 127 ++ apps/web/src/pages/server/files.tsx | 279 ++++ apps/web/src/pages/server/players.tsx | 13 + apps/web/src/pages/server/plugins.tsx | 13 + apps/web/src/pages/server/schedules.tsx | 13 + apps/web/src/pages/server/settings.tsx | 101 ++ apps/web/src/pages/servers/create.tsx | 277 ++++ apps/web/src/pages/settings/members.tsx | 143 ++ apps/web/src/stores/auth.ts | 69 + pnpm-lock.yaml | 1216 +++++++++++++++++ 43 files changed, 4431 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/layout/app-layout.tsx create mode 100644 apps/web/src/components/layout/header.tsx create mode 100644 apps/web/src/components/layout/server-layout.tsx create mode 100644 apps/web/src/components/layout/sidebar.tsx create mode 100644 apps/web/src/components/server/power-controls.tsx create mode 100644 apps/web/src/components/ui/badge.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/dropdown-menu.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/components/ui/progress.tsx create mode 100644 apps/web/src/components/ui/scroll-area.tsx create mode 100644 apps/web/src/components/ui/select.tsx create mode 100644 apps/web/src/components/ui/separator.tsx create mode 100644 apps/web/src/components/ui/tabs.tsx create mode 100644 apps/web/src/components/ui/tooltip.tsx create mode 100644 apps/web/src/hooks/use-theme.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/socket.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/pages/admin/audit-logs.tsx create mode 100644 apps/web/src/pages/admin/games.tsx create mode 100644 apps/web/src/pages/admin/users.tsx create mode 100644 apps/web/src/pages/auth/login.tsx create mode 100644 apps/web/src/pages/auth/register.tsx create mode 100644 apps/web/src/pages/dashboard/index.tsx create mode 100644 apps/web/src/pages/nodes/index.tsx create mode 100644 apps/web/src/pages/organizations/index.tsx create mode 100644 apps/web/src/pages/server/backups.tsx create mode 100644 apps/web/src/pages/server/console.tsx create mode 100644 apps/web/src/pages/server/files.tsx create mode 100644 apps/web/src/pages/server/players.tsx create mode 100644 apps/web/src/pages/server/plugins.tsx create mode 100644 apps/web/src/pages/server/schedules.tsx create mode 100644 apps/web/src/pages/server/settings.tsx create mode 100644 apps/web/src/pages/servers/create.tsx create mode 100644 apps/web/src/pages/settings/members.tsx create mode 100644 apps/web/src/stores/auth.ts diff --git a/apps/web/package.json b/apps/web/package.json index f3ad613..4e03e42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,13 +10,33 @@ "lint": "eslint src/" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", "@source/shared": "workspace:*", "@source/ui": "workspace:*", "@tanstack/react-query": "^5.62.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.0", + "lucide-react": "^0.575.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.0", - "socket.io-client": "^4.8.0" + "socket.io-client": "^4.8.0", + "sonner": "^2.0.7", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b544bb6..5e8cd06 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,38 @@ +import { useEffect } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter, Routes, Route } from 'react-router'; +import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router'; +import { Toaster } from 'sonner'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { useAuthStore } from '@/stores/auth'; + +// Layouts +import { AppLayout } from '@/components/layout/app-layout'; +import { ServerLayout } from '@/components/layout/server-layout'; + +// Auth pages +import { LoginPage } from '@/pages/auth/login'; +import { RegisterPage } from '@/pages/auth/register'; + +// App pages +import { OrganizationsPage } from '@/pages/organizations/index'; +import { DashboardPage } from '@/pages/dashboard/index'; +import { CreateServerPage } from '@/pages/servers/create'; +import { NodesPage } from '@/pages/nodes/index'; +import { MembersPage } from '@/pages/settings/members'; + +// Server pages +import { ConsolePage } from '@/pages/server/console'; +import { FilesPage } from '@/pages/server/files'; +import { BackupsPage } from '@/pages/server/backups'; +import { SchedulesPage } from '@/pages/server/schedules'; +import { PluginsPage } from '@/pages/server/plugins'; +import { PlayersPage } from '@/pages/server/players'; +import { ServerSettingsPage } from '@/pages/server/settings'; + +// Admin pages +import { AdminUsersPage } from '@/pages/admin/users'; +import { AdminGamesPage } from '@/pages/admin/games'; +import { AdminAuditLogsPage } from '@/pages/admin/audit-logs'; const queryClient = new QueryClient({ defaultOptions: { @@ -10,24 +43,76 @@ const queryClient = new QueryClient({ }, }); +function AuthGuard() { + const { isAuthenticated, isLoading, fetchUser } = useAuthStore(); + + useEffect(() => { + fetchUser(); + }, [fetchUser]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} + export function App() { return ( - - - -
-

GamePanel

-

Game Server Management Panel

-
-
- } - /> - - + + + + {/* Public routes */} + } /> + } /> + + {/* Protected routes */} + }> + }> + {/* Organizations */} + } /> + + {/* Org-scoped routes */} + } /> + } /> + } /> + } /> + + {/* Server detail */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin */} + } /> + } /> + } /> + } /> + + + + {/* Fallback */} + } /> + + + + ); } diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx new file mode 100644 index 0000000..2abb765 --- /dev/null +++ b/apps/web/src/components/layout/app-layout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from 'react-router'; +import { Sidebar } from './sidebar'; +import { Header } from './header'; + +export function AppLayout() { + return ( +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx new file mode 100644 index 0000000..2c6f45a --- /dev/null +++ b/apps/web/src/components/layout/header.tsx @@ -0,0 +1,56 @@ +import { useNavigate } from 'react-router'; +import { LogOut, User, Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useAuthStore } from '@/stores/auth'; +import { useTheme } from '@/hooks/use-theme'; + +export function Header() { + const navigate = useNavigate(); + const { user, logout } = useAuthStore(); + const { theme, toggleTheme } = useTheme(); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + return ( +
+
+
+ + + + + + + + {user?.email} + + navigate('/account/security')}> + Account Settings + + + + + Logout + + + +
+
+ ); +} diff --git a/apps/web/src/components/layout/server-layout.tsx b/apps/web/src/components/layout/server-layout.tsx new file mode 100644 index 0000000..39602e3 --- /dev/null +++ b/apps/web/src/components/layout/server-layout.tsx @@ -0,0 +1,89 @@ +import { Outlet, useParams, Link, useLocation } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle } from 'lucide-react'; +import { cn } from '@source/ui'; +import { api } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; +import { PowerControls } from '@/components/server/power-controls'; +import { statusBadgeVariant } from '@/lib/utils'; + +interface ServerDetail { + id: string; + uuid: string; + name: string; + status: string; + nodeName: string; + nodeFqdn: string; + gameName: string; + gameSlug: string; + port: number; + memoryLimit: number; + diskLimit: number; + cpuLimit: number; +} + +const tabs = [ + { label: 'Console', path: 'console', icon: Terminal }, + { label: 'Files', path: 'files', icon: FolderOpen }, + { label: 'Backups', path: 'backups', icon: HardDrive }, + { label: 'Schedules', path: 'schedules', icon: Calendar }, + { label: 'Plugins', path: 'plugins', icon: Puzzle }, + { label: 'Players', path: 'players', icon: Users }, + { label: 'Settings', path: 'settings', icon: Settings }, +]; + +export function ServerLayout() { + const { orgId, serverId } = useParams(); + const location = useLocation(); + + const { data: server } = useQuery({ + queryKey: ['server', orgId, serverId], + queryFn: () => api.get(`/organizations/${orgId}/servers/${serverId}`), + }); + + const currentTab = location.pathname.split('/').pop(); + + return ( +
+
+
+
+

{server?.name ?? 'Loading...'}

+ {server && ( + {server.status} + )} +
+ {server && ( +

+ {server.gameName} · {server.nodeFqdn}:{server.port} · {server.uuid} +

+ )} +
+ {server && } +
+ + + + +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..953d5f7 --- /dev/null +++ b/apps/web/src/components/layout/sidebar.tsx @@ -0,0 +1,115 @@ +import { Link, useLocation, useParams } from 'react-router'; +import { + Server, + LayoutDashboard, + Network, + Settings, + Users, + Shield, + Gamepad2, + ScrollText, + ChevronLeft, +} from 'lucide-react'; +import { cn } from '@source/ui'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { useAuthStore } from '@/stores/auth'; + +interface NavItem { + label: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +export function Sidebar() { + const location = useLocation(); + const { orgId } = useParams(); + const user = useAuthStore((s) => s.user); + + const orgNav: NavItem[] = orgId + ? [ + { label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard }, + { label: 'Servers', href: `/org/${orgId}/servers`, icon: Server }, + { label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network }, + { label: 'Members', href: `/org/${orgId}/settings/members`, icon: Users }, + { label: 'Settings', href: `/org/${orgId}/settings`, icon: Settings }, + ] + : []; + + const adminNav: NavItem[] = user?.isSuperAdmin + ? [ + { label: 'Users', href: '/admin/users', icon: Users }, + { label: 'Games', href: '/admin/games', icon: Gamepad2 }, + { label: 'Nodes', href: '/admin/nodes', icon: Network }, + { label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText }, + ] + : []; + + return ( +
+
+ + GamePanel +
+ + + {orgId && ( +
+
+ + Organizations + +
+ +
+ )} + + {!orgId && ( +
+

ORGANIZATIONS

+ + + +
+ )} + + {adminNav.length > 0 && ( + <> + +
+

ADMIN

+ +
+ + )} +
+
+ ); +} + +function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: string }) { + return ( + + ); +} diff --git a/apps/web/src/components/server/power-controls.tsx b/apps/web/src/components/server/power-controls.tsx new file mode 100644 index 0000000..e4da16b --- /dev/null +++ b/apps/web/src/components/server/power-controls.tsx @@ -0,0 +1,102 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Play, Square, RotateCcw, Skull } from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog'; + +interface PowerControlsProps { + serverId: string; + orgId: string; + status: string; +} + +export function PowerControls({ serverId, orgId, status }: PowerControlsProps) { + const queryClient = useQueryClient(); + + const powerMutation = useMutation({ + mutationFn: (action: string) => + api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] }); + queryClient.invalidateQueries({ queryKey: ['servers', orgId] }); + }, + }); + + const isRunning = status === 'running'; + const isStopped = status === 'stopped' || status === 'error'; + const isTransitioning = status === 'starting' || status === 'stopping' || status === 'installing'; + + return ( +
+ + + + + + + + + + + + + Kill Server + + This will forcefully terminate the server process. Any unsaved data may be lost. + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..e42ee0f --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@source/ui'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground shadow', + secondary: 'border-transparent bg-secondary text-secondary-foreground', + destructive: 'border-transparent bg-destructive text-destructive-foreground shadow', + outline: 'text-foreground', + }, + }, + defaultVariants: { variant: 'default' }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..5824756 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@source/ui'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..f79b730 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { cn } from '@source/ui'; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
, +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..2c86098 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { cn } from '@source/ui'; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogClose = DialogPrimitive.Close; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..330b6cb --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { cn } from '@source/ui'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0', + inset && 'pl-8', + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, + DropdownMenuGroup, +}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..b73eea7 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { cn } from '@source/ui'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..40e63c7 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cn } from '@source/ui'; + +const Label = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx new file mode 100644 index 0000000..b017b9f --- /dev/null +++ b/apps/web/src/components/ui/progress.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import * as ProgressPrimitive from '@radix-ui/react-progress'; +import { cn } from '@source/ui'; + +const Progress = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..9e48f31 --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +import { cn } from '@source/ui'; + +const ScrollArea = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +export { ScrollArea }; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx new file mode 100644 index 0000000..352320c --- /dev/null +++ b/apps/web/src/components/ui/select.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { ChevronDown, ChevronUp, Check } from 'lucide-react'; +import { cn } from '@source/ui'; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + + + {children} + + + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..98c6248 --- /dev/null +++ b/apps/web/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import { cn } from '@source/ui'; + +const Separator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..ce62344 --- /dev/null +++ b/apps/web/src/components/ui/tabs.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { cn } from '@source/ui'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..90d707c --- /dev/null +++ b/apps/web/src/components/ui/tooltip.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { cn } from '@source/ui'; + +const TooltipProvider = TooltipPrimitive.Provider; +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts new file mode 100644 index 0000000..2db98ce --- /dev/null +++ b/apps/web/src/hooks/use-theme.ts @@ -0,0 +1,25 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +function getTheme(): 'dark' | 'light' { + return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; +} + +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useTheme() { + const theme = useSyncExternalStore(subscribe, getTheme); + + const toggleTheme = useCallback(() => { + const next = getTheme() === 'dark' ? 'light' : 'dark'; + document.documentElement.classList.toggle('dark', next === 'dark'); + localStorage.setItem('theme', next); + listeners.forEach((l) => l()); + }, []); + + return { theme, toggleTheme }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..7b31bad --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,99 @@ +const API_BASE = '/api'; + +interface RequestOptions extends RequestInit { + params?: Record; +} + +class ApiError extends Error { + constructor( + public status: number, + public data: unknown, + ) { + super(`API Error ${status}`); + this.name = 'ApiError'; + } +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { params, ...fetchOptions } = options; + + let url = `${API_BASE}${path}`; + if (params) { + const searchParams = new URLSearchParams(params); + url += `?${searchParams.toString()}`; + } + + const token = localStorage.getItem('access_token'); + const headers: Record = { + ...(fetchOptions.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (fetchOptions.body && typeof fetchOptions.body === 'string') { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(url, { ...fetchOptions, headers }); + + if (res.status === 401) { + // Try refresh + const refreshed = await refreshToken(); + if (refreshed) { + headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`; + const retry = await fetch(url, { ...fetchOptions, headers }); + if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null)); + if (retry.status === 204) return undefined as T; + return retry.json(); + } + localStorage.removeItem('access_token'); + window.location.href = '/login'; + throw new ApiError(401, null); + } + + if (!res.ok) { + throw new ApiError(res.status, await res.json().catch(() => null)); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +async function refreshToken(): Promise { + try { + const res = await fetch(`${API_BASE}/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) return false; + const data = await res.json(); + localStorage.setItem('access_token', data.accessToken); + return true; + } catch { + return false; + } +} + +export const api = { + get: (path: string, params?: Record) => + request(path, { params }), + + post: (path: string, body?: unknown) => + request(path, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }), + + patch: (path: string, body?: unknown) => + request(path, { + method: 'PATCH', + body: body ? JSON.stringify(body) : undefined, + }), + + delete: (path: string) => + request(path, { method: 'DELETE' }), +}; + +export { ApiError }; diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts new file mode 100644 index 0000000..c2043c8 --- /dev/null +++ b/apps/web/src/lib/socket.ts @@ -0,0 +1,30 @@ +import { io, Socket } from 'socket.io-client'; + +let socket: Socket | null = null; + +export function getSocket(): Socket { + if (!socket) { + socket = io('/', { + path: '/socket.io', + auth: { + token: localStorage.getItem('access_token'), + }, + autoConnect: false, + }); + } + return socket; +} + +export function connectSocket() { + const s = getSocket(); + if (!s.connected) { + s.auth = { token: localStorage.getItem('access_token') }; + s.connect(); + } +} + +export function disconnectSocket() { + if (socket?.connected) { + socket.disconnect(); + } +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..66745f7 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,51 @@ +export function formatBytes(bytes: number, decimals = 1): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`; +} + +export function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +export function statusColor(status: string): string { + switch (status) { + case 'running': + return 'text-green-500'; + case 'stopped': + return 'text-red-500'; + case 'starting': + case 'stopping': + case 'installing': + return 'text-yellow-500'; + case 'suspended': + return 'text-orange-500'; + case 'error': + return 'text-destructive'; + default: + return 'text-muted-foreground'; + } +} + +export function statusBadgeVariant( + status: string, +): 'default' | 'secondary' | 'destructive' | 'outline' { + switch (status) { + case 'running': + return 'default'; + case 'stopped': + return 'secondary'; + case 'error': + case 'suspended': + return 'destructive'; + default: + return 'outline'; + } +} diff --git a/apps/web/src/pages/admin/audit-logs.tsx b/apps/web/src/pages/admin/audit-logs.tsx new file mode 100644 index 0000000..483791f --- /dev/null +++ b/apps/web/src/pages/admin/audit-logs.tsx @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +interface AuditLog { + id: string; + action: string; + username: string; + ipAddress: string | null; + metadata: Record; + createdAt: string; +} + +interface PaginatedResponse { + data: T[]; + meta: { total: number }; +} + +export function AdminAuditLogsPage() { + const { data } = useQuery({ + queryKey: ['admin-audit-logs'], + queryFn: () => api.get>('/admin/audit-logs'), + }); + + const logs = data?.data ?? []; + + return ( +
+

Audit Logs

+ + +
+ {logs.map((log) => ( +
+
+ {log.action} + + {log.username} + {log.ipAddress && ( + from {log.ipAddress} + )} + +
+ + {new Date(log.createdAt).toLocaleString()} + +
+ ))} + {logs.length === 0 && ( +
No audit logs
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/admin/games.tsx b/apps/web/src/pages/admin/games.tsx new file mode 100644 index 0000000..da5d88a --- /dev/null +++ b/apps/web/src/pages/admin/games.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Gamepad2 } from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } 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; + dockerImage: string; + defaultPort: number; + startupCommand: string; +} + +interface PaginatedResponse { + data: T[]; + meta: { total: number }; +} + +export function AdminGamesPage() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [dockerImage, setDockerImage] = useState(''); + const [defaultPort, setDefaultPort] = useState(25565); + const [startupCommand, setStartupCommand] = useState(''); + + const { data } = useQuery({ + queryKey: ['admin-games'], + queryFn: () => api.get>('/admin/games'), + }); + + const createMutation = useMutation({ + mutationFn: (body: Record) => api.post('/admin/games', body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-games'] }); + setOpen(false); + setName(''); + setSlug(''); + setDockerImage(''); + setStartupCommand(''); + }, + }); + + const games = data?.data ?? []; + + return ( +
+
+

Games

+ + + + + + + Add Game + +
{ + e.preventDefault(); + createMutation.mutate({ name, slug, dockerImage, defaultPort, startupCommand }); + }} + className="space-y-4" + > +
+ + setName(e.target.value)} required /> +
+
+ + + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')) + } + required + /> +
+
+ + setDockerImage(e.target.value)} + placeholder="itzg/minecraft-server:latest" + required + /> +
+
+ + setDefaultPort(Number(e.target.value))} + /> +
+
+ + setStartupCommand(e.target.value)} + required + /> +
+ + + +
+
+
+
+ +
+ {games.map((game) => ( + + + + {game.name} + + +
+

+ {game.slug} +

+

{game.dockerImage}

+

Port: {game.defaultPort}

+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/pages/admin/users.tsx b/apps/web/src/pages/admin/users.tsx new file mode 100644 index 0000000..509fc94 --- /dev/null +++ b/apps/web/src/pages/admin/users.tsx @@ -0,0 +1,64 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface User { + id: string; + email: string; + username: string; + isSuperAdmin: boolean; + createdAt: string; +} + +interface PaginatedResponse { + data: T[]; + meta: { total: number }; +} + +export function AdminUsersPage() { + const { data } = useQuery({ + queryKey: ['admin-users'], + queryFn: () => api.get>('/admin/users'), + }); + + const users = data?.data ?? []; + + return ( +
+

Users

+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
UsernameEmailRoleCreated
{user.username}{user.email} + {user.isSuperAdmin ? ( + Admin + ) : ( + User + )} + + {new Date(user.createdAt).toLocaleDateString()} +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx new file mode 100644 index 0000000..98518e3 --- /dev/null +++ b/apps/web/src/pages/auth/login.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { Shield } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAuthStore } from '@/stores/auth'; +import { ApiError } from '@/lib/api'; + +export function LoginPage() { + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(email, password); + navigate('/'); + } catch (err) { + if (err instanceof ApiError) { + setError(err.status === 401 ? 'Invalid email or password' : 'An error occurred'); + } else { + setError('An error occurred'); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ +
+ Welcome back + Sign in to your GamePanel account +
+
+ + {error && ( +
{error}
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+
+ ); +} diff --git a/apps/web/src/pages/auth/register.tsx b/apps/web/src/pages/auth/register.tsx new file mode 100644 index 0000000..d3c8546 --- /dev/null +++ b/apps/web/src/pages/auth/register.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { Shield } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAuthStore } from '@/stores/auth'; +import { ApiError } from '@/lib/api'; + +export function RegisterPage() { + const navigate = useNavigate(); + const register = useAuthStore((s) => s.register); + const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await register(email, username, password); + navigate('/'); + } catch (err) { + if (err instanceof ApiError) { + setError(err.status === 409 ? 'Email or username already taken' : 'An error occurred'); + } else { + setError('An error occurred'); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ +
+ Create an account + Get started with GamePanel +
+
+ + {error && ( +
{error}
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+
+ ); +} diff --git a/apps/web/src/pages/dashboard/index.tsx b/apps/web/src/pages/dashboard/index.tsx new file mode 100644 index 0000000..3a238e1 --- /dev/null +++ b/apps/web/src/pages/dashboard/index.tsx @@ -0,0 +1,125 @@ +import { useParams, Link } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Server, Network, Activity, Plus } from 'lucide-react'; +import { api } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle } 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 { + data: T[]; + meta: { total: number; page: number; perPage: number; totalPages: number }; +} + +export function DashboardPage() { + const { orgId } = useParams(); + + const { data: serversData } = useQuery({ + queryKey: ['servers', orgId], + queryFn: () => api.get>(`/organizations/${orgId}/servers`), + }); + + const { data: nodesData } = useQuery({ + queryKey: ['nodes', orgId], + queryFn: () => api.get>(`/organizations/${orgId}/nodes`), + }); + + const servers = serversData?.data ?? []; + const running = servers.filter((s) => s.status === 'running').length; + const totalNodes = nodesData?.meta.total ?? 0; + + return ( +
+
+

Dashboard

+ + + +
+ +
+ + + Total Servers + + + +
{servers.length}
+
+
+ + + Running + + + +
{running}
+
+
+ + + Nodes + + + +
{totalNodes}
+
+
+
+ +
+

Servers

+ {servers.length === 0 ? ( + + + +

No servers yet

+ + + +
+
+ ) : ( +
+ {servers.map((server) => ( + + + +
+
+ +
+
+

{server.name}

+

+ {server.gameName} · {server.nodeName} · :{server.port} +

+
+
+ {server.status} +
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/nodes/index.tsx b/apps/web/src/pages/nodes/index.tsx new file mode 100644 index 0000000..83af433 --- /dev/null +++ b/apps/web/src/pages/nodes/index.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Network, Wifi, WifiOff } from 'lucide-react'; +import { api } from '@/lib/api'; +import { formatBytes } from '@/lib/utils'; +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 NodeItem { + id: string; + name: string; + fqdn: string; + daemonPort: number; + grpcPort: number; + memoryTotal: number; + diskTotal: number; + isOnline: boolean; +} + +interface PaginatedResponse { + data: T[]; + meta: { total: number }; +} + +export function NodesPage() { + const { orgId } = useParams(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [fqdn, setFqdn] = useState(''); + const [daemonPort, setDaemonPort] = useState(8443); + const [grpcPort, setGrpcPort] = useState(50051); + const [memoryTotal, setMemoryTotal] = useState(8192); + const [diskTotal, setDiskTotal] = useState(51200); + + const { data } = useQuery({ + queryKey: ['nodes', orgId], + queryFn: () => api.get>(`/organizations/${orgId}/nodes`), + }); + + const createMutation = useMutation({ + mutationFn: (body: Record) => + api.post(`/organizations/${orgId}/nodes`, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['nodes', orgId] }); + setOpen(false); + setName(''); + setFqdn(''); + }, + }); + + const nodes = data?.data ?? []; + + return ( +
+
+
+

Nodes

+

Manage your daemon nodes

+
+ + + + + + + Add Node + +
{ + e.preventDefault(); + createMutation.mutate({ + name, + fqdn, + daemonPort, + grpcPort, + memoryTotal: memoryTotal * 1024 * 1024, + diskTotal: diskTotal * 1024 * 1024, + }); + }} + className="space-y-4" + > +
+
+ + setName(e.target.value)} required /> +
+
+ + setFqdn(e.target.value)} + placeholder="node1.example.com" + required + /> +
+
+ + setDaemonPort(Number(e.target.value))} + /> +
+
+ + setGrpcPort(Number(e.target.value))} + /> +
+
+ + setMemoryTotal(Number(e.target.value))} + /> +
+
+ + setDiskTotal(Number(e.target.value))} + /> +
+
+ + + +
+
+
+
+ +
+ {nodes.map((node) => ( + + +
+ + {node.name} +
+ + {node.isOnline ? ( + <> Online + ) : ( + <> Offline + )} + +
+ +

{node.fqdn}:{node.daemonPort}

+
+ {formatBytes(node.memoryTotal)} RAM + {formatBytes(node.diskTotal)} Disk +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/pages/organizations/index.tsx b/apps/web/src/pages/organizations/index.tsx new file mode 100644 index 0000000..48b8fd4 --- /dev/null +++ b/apps/web/src/pages/organizations/index.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Building2 } from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +interface Organization { + id: string; + name: string; + slug: string; + maxServers: number; + maxNodes: number; + createdAt: string; +} + +interface PaginatedResponse { + data: T[]; + meta: { total: number; page: number; perPage: number; totalPages: number }; +} + +export function OrganizationsPage() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + + const { data } = useQuery({ + queryKey: ['organizations'], + queryFn: () => api.get>('/organizations'), + }); + + const createMutation = useMutation({ + mutationFn: (body: { name: string; slug: string }) => api.post('/organizations', body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['organizations'] }); + setOpen(false); + setName(''); + setSlug(''); + }, + }); + + return ( +
+
+
+

Organizations

+

Manage your organizations

+
+ + + + + + + Create Organization + +
{ + e.preventDefault(); + createMutation.mutate({ name, slug }); + }} + className="space-y-4" + > +
+ + setName(e.target.value)} required /> +
+
+ + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + pattern="^[a-z0-9-]+$" + required + /> +
+ + + +
+
+
+
+ +
+ {data?.data.map((org) => ( + + + +
+
+ +
+
+ {org.name} + {org.slug} +
+
+
+ +
+ Max {org.maxServers} servers + Max {org.maxNodes} nodes +
+
+
+ + ))} +
+
+ ); +} diff --git a/apps/web/src/pages/server/backups.tsx b/apps/web/src/pages/server/backups.tsx new file mode 100644 index 0000000..711e0a0 --- /dev/null +++ b/apps/web/src/pages/server/backups.tsx @@ -0,0 +1,17 @@ +import { useParams } from 'react-router'; +import { HardDrive } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; + +export function BackupsPage() { + const { serverId } = useParams(); + + return ( + + + +

Backup management coming soon

+

Server: {serverId}

+
+
+ ); +} diff --git a/apps/web/src/pages/server/console.tsx b/apps/web/src/pages/server/console.tsx new file mode 100644 index 0000000..a8d5356 --- /dev/null +++ b/apps/web/src/pages/server/console.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import '@xterm/xterm/css/xterm.css'; +import { getSocket, connectSocket } from '@/lib/socket'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Send } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; + +export function ConsolePage() { + const { orgId, serverId } = useParams(); + const termRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const [command, setCommand] = useState(''); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + useEffect(() => { + if (!termRef.current) return; + + const terminal = new Terminal({ + cursorBlink: false, + disableStdin: true, + fontSize: 13, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + theme: { + background: '#0a0a0f', + foreground: '#d4d4d8', + cursor: '#d4d4d8', + selectionBackground: '#27272a', + }, + scrollback: 5000, + convertEol: true, + }); + + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.loadAddon(new WebLinksAddon()); + terminal.open(termRef.current); + fitAddon.fit(); + + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + + terminal.writeln('\x1b[90m--- Console connected ---\x1b[0m'); + + // Socket.IO connection + connectSocket(); + const socket = getSocket(); + + socket.emit('server:console:join', { serverId }); + + const handleOutput = (data: { line: string }) => { + terminal.writeln(data.line); + }; + + socket.on('server:console:output', handleOutput); + + const handleResize = () => fitAddon.fit(); + window.addEventListener('resize', handleResize); + + return () => { + socket.off('server:console:output', handleOutput); + socket.emit('server:console:leave', { serverId }); + window.removeEventListener('resize', handleResize); + terminal.dispose(); + }; + }, [serverId]); + + const sendCommand = () => { + if (!command.trim()) return; + const socket = getSocket(); + socket.emit('server:console:command', { serverId, orgId, command: command.trim() }); + setHistory((prev) => [...prev, command.trim()]); + setHistoryIndex(-1); + setCommand(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + sendCommand(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (history.length === 0) return; + const newIndex = historyIndex < history.length - 1 ? historyIndex + 1 : historyIndex; + setHistoryIndex(newIndex); + setCommand(history[history.length - 1 - newIndex] ?? ''); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex <= 0) { + setHistoryIndex(-1); + setCommand(''); + } else { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setCommand(history[history.length - 1 - newIndex] ?? ''); + } + } + }; + + return ( +
+ + +
+ + + +
+ setCommand(e.target.value)} + onKeyDown={handleKeyDown} + className="font-mono text-sm" + /> + +
+
+ ); +} diff --git a/apps/web/src/pages/server/files.tsx b/apps/web/src/pages/server/files.tsx new file mode 100644 index 0000000..7d92be3 --- /dev/null +++ b/apps/web/src/pages/server/files.tsx @@ -0,0 +1,279 @@ +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Folder, + FileText, + ArrowUp, + Trash2, + Plus, + Download, + Upload, + Save, + X, +} from 'lucide-react'; +import { api } from '@/lib/api'; +import { formatBytes } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogClose, +} from '@/components/ui/dialog'; + +interface FileEntry { + name: string; + path: string; + isDirectory: boolean; + size: number; + modifiedAt: number; +} + +export function FilesPage() { + const { orgId, serverId } = useParams(); + const queryClient = useQueryClient(); + const [currentPath, setCurrentPath] = useState('/'); + const [editingFile, setEditingFile] = useState<{ path: string; content: string } | null>(null); + const [newFileName, setNewFileName] = useState(''); + const [showNewFile, setShowNewFile] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const filesQuery = useQuery({ + queryKey: ['files', orgId, serverId, currentPath], + queryFn: () => + api.get<{ files: FileEntry[] }>( + `/organizations/${orgId}/servers/${serverId}/files`, + { path: currentPath }, + ), + enabled: !editingFile, + }); + + const deleteMutation = useMutation({ + mutationFn: (paths: string[]) => + api.post(`/organizations/${orgId}/servers/${serverId}/files/delete`, { paths }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] }); + setDeleteTarget(null); + }, + }); + + const saveMutation = useMutation({ + mutationFn: ({ path, data }: { path: string; data: string }) => + api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }), + onSuccess: () => { + setEditingFile(null); + queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] }); + }, + }); + + const createFileMutation = useMutation({ + mutationFn: ({ path, data }: { path: string; data: string }) => + api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }), + onSuccess: () => { + setShowNewFile(false); + setNewFileName(''); + queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] }); + }, + }); + + const openFile = async (file: FileEntry) => { + if (file.isDirectory) { + setCurrentPath(file.path); + return; + } + const res = await api.get<{ data: string }>( + `/organizations/${orgId}/servers/${serverId}/files/read`, + { path: file.path }, + ); + setEditingFile({ path: file.path, content: res.data }); + }; + + const goUp = () => { + if (currentPath === '/') return; + const parts = currentPath.split('/').filter(Boolean); + parts.pop(); + setCurrentPath('/' + parts.join('/')); + }; + + const breadcrumbs = currentPath.split('/').filter(Boolean); + + const files = filesQuery.data?.files ?? []; + + return ( +
+ {editingFile ? ( + + + {editingFile.path} +
+ + +
+
+ +