fix: frontend-backend integration — 14 issues resolved
- nginx: add proxy routes for /settings/, /templates/, /ipam/, /logs/, /admin/, /health
- dashboard: fix data format mismatch (servers[] not nodes[]/vms[])
- dashboard: add WebSocket subscription to dashboard_metrics for real-time updates
- dashboard: connect activity feed to /logs/api/logs endpoint
- dashboard: reduce polling to 15s with WS as primary update source
- graphs: fix rrddata response unwrap (backend returns {data:[],timeframe:''})
- graphs: add i18n translations for chart titles
- console: add xterm.js CSS import for proper terminal rendering
- console: add i18n translations (connecting, reconnect)
- workspace: add switcher dropdown in TopBar
- workspace: add edit dialog in WorkspacesPage
- workspace: create Zustand store with localStorage persistence
- workspace: add X-Active-Workspace header to API client
- sidebar: replace generic icon with actual favicon.svg logo
- instances: translate RowActionMenu and InstanceDetailPage strings
- locales: add graphs, console, workspace, instance tab keys to en/ru
This commit is contained in:
34
frontend/src/App.tsx
Normal file
34
frontend/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { router } from '@/router';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delay={200}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
91
frontend/src/components/shared/NotificationsDropdown.tsx
Normal file
91
frontend/src/components/shared/NotificationsDropdown.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, Check, CheckCheck, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Notification } from '@/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkRead,
|
||||
useMarkAllRead,
|
||||
useDeleteNotification,
|
||||
} from '@/hooks/use-notifications';
|
||||
|
||||
export default function NotificationsDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const { data: notifResponse } = useNotifications();
|
||||
const { data: countData } = useUnreadCount();
|
||||
const markRead = useMarkRead();
|
||||
const markAllRead = useMarkAllRead();
|
||||
const deleteNotification = useDeleteNotification();
|
||||
|
||||
const notifications: Notification[] = notifResponse?.notifications ?? [];
|
||||
const unread = countData?.count ?? 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 px-1 text-[10px]"
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent align="end" className="w-80 max-h-96 overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-sm font-semibold">{t('notifications.title')}</span>
|
||||
{unread > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => markAllRead.mutate()}>
|
||||
<CheckCheck className="h-3 w-3 mr-1" />{t('notifications.mark_all_read')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{t('notifications.empty')}</div>
|
||||
) : (
|
||||
notifications.slice(0, 20).map(n => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" onSelect={e => e.preventDefault()}>
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm leading-tight ${n.is_read ? 'text-muted-foreground' : 'font-medium'}`}>
|
||||
{n.title || n.message}
|
||||
</p>
|
||||
{n.title && n.message && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{n.created_at ? new Date(n.created_at).toLocaleString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
{!n.is_read && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => markRead.mutate(n.id)}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={() => deleteNotification.mutate(n.id)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/shared/route-guards.tsx
Normal file
30
frontend/src/components/shared/route-guards.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Navigate, Outlet } from 'react-router';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function PublicRoute() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
28
frontend/src/components/shared/status-dot.tsx
Normal file
28
frontend/src/components/shared/status-dot.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-500',
|
||||
stopped: 'bg-muted-foreground/40',
|
||||
paused: 'bg-amber-500',
|
||||
suspended: 'bg-amber-500',
|
||||
unknown: 'bg-muted-foreground/30',
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-red-500',
|
||||
};
|
||||
|
||||
interface StatusDotProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export function StatusDot({ status, className, pulse }: StatusDotProps) {
|
||||
const color = statusColors[status] ?? statusColors.unknown;
|
||||
return (
|
||||
<span className={cn('relative inline-block h-2 w-2 rounded-full', color, className)}>
|
||||
{pulse && status === 'running' && (
|
||||
<span className={cn('absolute inset-0 animate-ping rounded-full opacity-75', color)} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/ui/avatar.tsx
Normal file
107
frontend/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
frontend/src/components/ui/badge.tsx
Normal file
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
frontend/src/components/ui/card.tsx
Normal file
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
160
frontend/src/components/ui/dialog.tsx
Normal file
160
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
20
frontend/src/components/ui/input.tsx
Normal file
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
18
frontend/src/components/ui/label.tsx
Normal file
18
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
201
frontend/src/components/ui/select.tsx
Normal file
201
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
25
frontend/src/components/ui/separator.tsx
Normal file
25
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
136
frontend/src/components/ui/sheet.tsx
Normal file
136
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
47
frontend/src/components/ui/sonner.tsx
Normal file
47
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
80
frontend/src/components/ui/tabs.tsx
Normal file
80
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
66
frontend/src/components/ui/tooltip.tsx
Normal file
66
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
87
frontend/src/features/auth/LoginPage.tsx
Normal file
87
frontend/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
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 { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login({ username, password });
|
||||
navigate('/dashboard');
|
||||
} catch {
|
||||
// Error is already handled in store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-lg">{t('login.title', 'Sign in to your account')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t('login.username', 'Username')}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
clearError();
|
||||
}}
|
||||
placeholder={t('login.username_placeholder', 'Enter username')}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('login.password', 'Password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
clearError();
|
||||
}}
|
||||
placeholder={t('login.password_placeholder', 'Enter password')}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t('login.signing_in', 'Signing in...')}
|
||||
</span>
|
||||
) : (
|
||||
t('login.sign_in', 'Sign in')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
150
frontend/src/features/backups/BackupsPage.tsx
Normal file
150
frontend/src/features/backups/BackupsPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Archive, Play, Calendar } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useBackupJobs, useBackupList, useBackupStorages, useToggleBackupJob, useRunBackupJob } from '@/hooks/use-backups';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function BackupsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState('files');
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [selectedNode, setSelectedNode] = useState('');
|
||||
const [selectedStorage, setSelectedStorage] = useState('');
|
||||
|
||||
const { data: servers = [] } = useServers();
|
||||
const sid = selectedServer ? Number(selectedServer) : 0;
|
||||
const { data: storagesData } = useBackupStorages(sid);
|
||||
const { data: backupsData } = useBackupList(sid, selectedNode, selectedStorage);
|
||||
const { data: jobsData } = useBackupJobs();
|
||||
const toggleJob = useToggleBackupJob();
|
||||
const runJob = useRunBackupJob();
|
||||
|
||||
const storages = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
|
||||
const backups = (backupsData?.backups || []) as { volid: string; vmid: number; size: number; ctime: number; format: string; notes?: string }[];
|
||||
const jobs = (jobsData?.jobs || []) as { id: number; server_id: number; vmids: number[]; storage: string; mode: string; cron_expression: string; enabled: boolean; notes?: string }[];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('nav.backups')}</h1>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="files">{t('backups.files')}</TabsTrigger>
|
||||
<TabsTrigger value="jobs">{t('backups.scheduled_jobs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="files" className="space-y-4 mt-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(''); setSelectedStorage(''); } }}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input className="w-[160px]" placeholder={t('backups.node')} value={selectedNode} onChange={e => setSelectedNode(e.target.value)} />
|
||||
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Backup files table */}
|
||||
{backups.length > 0 ? (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>VMID</TableHead>
|
||||
<TableHead>{t('backups.volume')}</TableHead>
|
||||
<TableHead>{t('backups.size')}</TableHead>
|
||||
<TableHead>{t('backups.date')}</TableHead>
|
||||
<TableHead>{t('backups.format')}</TableHead>
|
||||
<TableHead>{t('backups.notes')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{backups.map((b, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono">{b.vmid}</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
|
||||
<TableCell>{formatBytes(b.size)}</TableCell>
|
||||
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
|
||||
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Archive className="mx-auto h-12 w-12 mb-3 opacity-50" />
|
||||
<p>{selectedServer ? t('backups.no_backups') : t('backups.select_server_first')}</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="space-y-4 mt-4">
|
||||
{jobs.length > 0 ? (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t('backups.vmids')}</TableHead>
|
||||
<TableHead>{t('backups.storage_col')}</TableHead>
|
||||
<TableHead>{t('backups.schedule')}</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead>{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map(job => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>{job.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{job.vmids?.join(', ') || '—'}</TableCell>
|
||||
<TableCell>{job.storage}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{job.cron_expression}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={job.enabled ? 'default' : 'secondary'}>
|
||||
{job.enabled ? t('backups.enabled') : t('backups.disabled')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => toggleJob.mutate(job.id)}>
|
||||
{job.enabled ? t('backups.disable') : t('backups.enable')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Calendar className="mx-auto h-12 w-12 mb-3 opacity-50" />
|
||||
<p>{t('backups.no_jobs')}</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
frontend/src/features/console/ConsolePage.tsx
Normal file
333
frontend/src/features/console/ConsolePage.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { Maximize, Minimize, Keyboard, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { vmTypeLabel } from '@/lib/format';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
|
||||
// noVNC and xterm.js will be loaded dynamically
|
||||
// We use dynamic imports for code splitting
|
||||
|
||||
interface VNCData {
|
||||
port: number;
|
||||
ticket: string;
|
||||
password?: string;
|
||||
host: string;
|
||||
node: string;
|
||||
vmid: number;
|
||||
type: string;
|
||||
auth_ticket?: string;
|
||||
}
|
||||
|
||||
export default function ConsolePage() {
|
||||
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const node = searchParams.get('node') || '';
|
||||
const type = searchParams.get('type') || 'qemu';
|
||||
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rfbRef = useRef<unknown>(null);
|
||||
const termRef = useRef<unknown>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const sid = Number(serverId);
|
||||
const vid = Number(vmid);
|
||||
|
||||
// ==================== VNC Console (QEMU) ====================
|
||||
const connectVNC = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<VNCData>(
|
||||
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
|
||||
);
|
||||
|
||||
// Dynamic import noVNC
|
||||
// @ts-expect-error noVNC doesn't have TS declarations
|
||||
const { default: RFB } = await import('@novnc/novnc/lib/rfb');
|
||||
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const params = new URLSearchParams({
|
||||
port: String(data.port),
|
||||
vncticket: data.ticket,
|
||||
});
|
||||
if (data.password) params.set('vnc_password', data.password);
|
||||
if (data.auth_ticket) params.set('auth_ticket', data.auth_ticket);
|
||||
|
||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
|
||||
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Pass URL string to RFB, NOT an open WebSocket
|
||||
const rfb = new RFB(containerRef.current, wsUrl, {
|
||||
credentials: { password: data.password || '' },
|
||||
});
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected');
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (e: CustomEvent) => {
|
||||
if (e.detail?.clean) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Connection closed');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setErrorMsg('Connection lost');
|
||||
}
|
||||
});
|
||||
|
||||
rfbRef.current = rfb;
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node]);
|
||||
|
||||
// ==================== Terminal Console (LXC) ====================
|
||||
const connectTerminal = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<VNCData>(
|
||||
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
|
||||
);
|
||||
|
||||
// Dynamic import xterm.js
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-web-links'),
|
||||
]);
|
||||
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
theme: {
|
||||
background: '#09090B',
|
||||
foreground: '#F0F0F3',
|
||||
cursor: '#F0F0F3',
|
||||
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
});
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(containerRef.current);
|
||||
|
||||
// Wait a frame, then fit
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
// WebSocket connection
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected');
|
||||
|
||||
// Send resize
|
||||
const { cols, rows } = terminal;
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
|
||||
// Keepalive every 120s
|
||||
keepaliveRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('2');
|
||||
}
|
||||
}, 120000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminal.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
terminal.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('error');
|
||||
setErrorMsg('Terminal connection closed');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus('error');
|
||||
setErrorMsg('Terminal connection error');
|
||||
};
|
||||
|
||||
// User input → WebSocket
|
||||
terminal.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const encoder = new TextEncoder();
|
||||
const byteLength = encoder.encode(data).byteLength;
|
||||
ws.send(`0:${byteLength}:${data}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Resize handler
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`1:${cols}:${rows}:`);
|
||||
}
|
||||
});
|
||||
|
||||
// Window resize
|
||||
const handleResize = () => fitAddon.fit();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, vid, node]);
|
||||
|
||||
// ==================== Connect on mount ====================
|
||||
useEffect(() => {
|
||||
if (type === 'qemu') {
|
||||
connectVNC();
|
||||
} else {
|
||||
connectTerminal();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
if (rfbRef.current) {
|
||||
try { (rfbRef.current as { disconnect: () => void }).disconnect(); } catch { /* */ }
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
if (keepaliveRef.current) {
|
||||
clearInterval(keepaliveRef.current);
|
||||
}
|
||||
if (termRef.current) {
|
||||
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
|
||||
t.cleanup();
|
||||
t.terminal.dispose();
|
||||
}
|
||||
};
|
||||
}, [type, connectVNC, connectTerminal]);
|
||||
|
||||
// ==================== Fullscreen ====================
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== Send Ctrl+Alt+Del (VNC) ====================
|
||||
const sendCtrlAltDel = () => {
|
||||
if (rfbRef.current) {
|
||||
(rfbRef.current as { sendCtrlAltDel: () => void }).sendCtrlAltDel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-[#09090B]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-[#111113] px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs border-border/50">
|
||||
{vmTypeLabel(type)} #{vmid}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">Node: {node}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${
|
||||
status === 'connected'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: status === 'connecting'
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: 'bg-red-500/10 text-red-500'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{type === 'qemu' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={sendCtrlAltDel}
|
||||
>
|
||||
<Keyboard className="mr-1 h-3 w-3" />
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize className="h-3.5 w-3.5" /> : <Maximize className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Console viewport */}
|
||||
<div className="relative flex-1">
|
||||
{status === 'connecting' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<p className="text-sm">{t('console.connecting')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm text-red-500">{errorMsg}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatus('connecting');
|
||||
setErrorMsg('');
|
||||
if (type === 'qemu') connectVNC();
|
||||
else connectTerminal();
|
||||
}}
|
||||
>
|
||||
{t('console.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ minHeight: 'calc(100vh - 40px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
frontend/src/features/dashboard/DashboardPage.tsx
Normal file
344
frontend/src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
Container,
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTasksWebSocket } from '@/lib/websocket';
|
||||
|
||||
// ==================== Stat Card ====================
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
subtitle?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, subtitle, icon: Icon, color = 'text-primary' }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 ${color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Resource Card ====================
|
||||
interface ResourceCardProps {
|
||||
label: string;
|
||||
percent: number;
|
||||
used: string;
|
||||
total: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
function ResourceCard({ label, percent, used, total, icon: Icon }: ResourceCardProps) {
|
||||
const barColor =
|
||||
percent >= 90 ? 'bg-red' : percent >= 70 ? 'bg-amber' : 'bg-green';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-2 text-2xl font-bold tabular-nums">{percent.toFixed(1)}%</p>
|
||||
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Activity Item ====================
|
||||
interface ActivityItem {
|
||||
time: string;
|
||||
user?: string;
|
||||
action: string;
|
||||
resource?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function ActivityFeed({ items }: { items: ActivityItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const typeColor: Record<string, string> = {
|
||||
success: 'bg-green/20 text-green',
|
||||
error: 'bg-red/20 text-red',
|
||||
warning: 'bg-amber/20 text-amber',
|
||||
info: 'bg-blue/20 text-blue',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">{t('dashboard.recent_activity', 'Recent Activity')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data', 'No recent activity')}</p>
|
||||
) : (
|
||||
items.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 text-sm">
|
||||
<span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground font-mono tabular-nums">
|
||||
{item.time}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground">{item.user && `${item.user} `}</span>
|
||||
<span>{item.action}</span>
|
||||
{item.resource && (
|
||||
<span className="font-medium"> {item.resource}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.type && (
|
||||
<Badge variant="secondary" className={`shrink-0 text-[10px] ${typeColor[item.type] ?? ''}`}>
|
||||
{item.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Format Helpers ====================
|
||||
function formatBytes(bytes: number): 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 `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// ==================== Dashboard Page ====================
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Fetch all resources
|
||||
const { data: resources } = useQuery({
|
||||
queryKey: ['resources'],
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
// Fetch alerts
|
||||
const { data: alerts } = useQuery({
|
||||
queryKey: ['dashboard-alerts'],
|
||||
queryFn: () => apiClient.get<{ alerts: unknown[]; count: number }>('/api/dashboard/alerts'),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
// Fetch recent activity from audit logs
|
||||
const { data: recentLogs } = useQuery({
|
||||
queryKey: ['dashboard-activity'],
|
||||
queryFn: () => apiClient.get<{ items: Array<{ timestamp: string; username?: string; message: string; category?: string; level?: string }> }>('/logs/api/logs?limit=10'),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Subscribe to WebSocket 'dashboard_metrics' for real-time updates
|
||||
useEffect(() => {
|
||||
const ws = getTasksWebSocket();
|
||||
if (!ws.isConnected) ws.connect();
|
||||
const unsub = ws.subscribe('dashboard_metrics', (data: unknown) => {
|
||||
const payload = data as { data?: Record<string, unknown> };
|
||||
if (payload?.data) {
|
||||
qc.setQueryData(['resources'], payload.data);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [qc]);
|
||||
|
||||
// Parse resource data — API returns { servers: [{vms, containers, nodes}], total_vms, total_containers }
|
||||
const serverList = ((resources as Record<string, unknown>)?.servers as Array<Record<string, unknown>>) ?? [];
|
||||
const allVMs = serverList.flatMap((s) => [
|
||||
...((s.vms as Array<Record<string, unknown>>) ?? []),
|
||||
...((s.containers as Array<Record<string, unknown>>) ?? []),
|
||||
]);
|
||||
|
||||
const nodesOnline = serverList.filter((s) => !s.error).length;
|
||||
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
|
||||
const lxcCTs = allVMs.filter((v) => v.type === 'lxc');
|
||||
const runningVMs = qemuVMs.filter((v) => v.status === 'running').length;
|
||||
const runningCTs = lxcCTs.filter((v) => v.status === 'running').length;
|
||||
|
||||
// Aggregate resource metrics from node-level data (WS) or fallback to VM-level
|
||||
const nodeStats = serverList.flatMap((s) => (s.nodes as Array<Record<string, unknown>>) ?? []);
|
||||
let cpuPercent = 0;
|
||||
let cpuUsedLabel = '';
|
||||
let cpuTotalLabel = '';
|
||||
let memPercent = 0;
|
||||
let memUsedLabel = '';
|
||||
let memTotalLabel = '';
|
||||
let diskPercent = 0;
|
||||
let diskUsedLabel = '';
|
||||
let diskTotalLabel = '';
|
||||
|
||||
if (nodeStats.length > 0) {
|
||||
// Use precise node-level stats from WebSocket metrics
|
||||
cpuPercent = nodeStats.reduce((s, n) => s + (Number(n.cpu_pct) || 0), 0) / nodeStats.length;
|
||||
cpuUsedLabel = `${cpuPercent.toFixed(1)}%`;
|
||||
cpuTotalLabel = `${nodeStats.length} nodes`;
|
||||
memPercent = nodeStats.reduce((s, n) => s + (Number(n.ram_pct) || 0), 0) / nodeStats.length;
|
||||
memUsedLabel = `${memPercent.toFixed(1)}%`;
|
||||
memTotalLabel = `${nodeStats.length} nodes`;
|
||||
diskPercent = nodeStats.reduce((s, n) => s + (Number(n.disk_pct) || 0), 0) / nodeStats.length;
|
||||
diskUsedLabel = `${diskPercent.toFixed(1)}%`;
|
||||
diskTotalLabel = `${nodeStats.length} nodes`;
|
||||
} else {
|
||||
// Fallback: aggregate from VM data
|
||||
const totalCPU = allVMs.reduce((s, v) => s + (Number(v.maxcpu) || 0), 0);
|
||||
const usedCPU = allVMs.reduce((s, v) => s + (Number(v.cpu) || 0) * (Number(v.maxcpu) || 0), 0);
|
||||
cpuPercent = totalCPU > 0 ? (usedCPU / totalCPU) * 100 : 0;
|
||||
cpuUsedLabel = `${usedCPU.toFixed(1)} cores`;
|
||||
cpuTotalLabel = `${totalCPU} cores`;
|
||||
|
||||
const totalMem = allVMs.reduce((s, v) => s + (Number(v.maxmem) || 0), 0);
|
||||
const usedMem = allVMs.reduce((s, v) => s + (Number(v.mem) || 0), 0);
|
||||
memPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
|
||||
memUsedLabel = formatBytes(usedMem);
|
||||
memTotalLabel = formatBytes(totalMem);
|
||||
|
||||
const totalDisk = allVMs.reduce((s, v) => s + (Number(v.maxdisk) || 0), 0);
|
||||
const usedDisk = allVMs.reduce((s, v) => s + (Number(v.disk) || 0), 0);
|
||||
diskPercent = totalDisk > 0 ? (usedDisk / totalDisk) * 100 : 0;
|
||||
diskUsedLabel = formatBytes(usedDisk);
|
||||
diskTotalLabel = formatBytes(totalDisk);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-[22px] font-semibold">{t('dashboard.title', 'Dashboard')}</h1>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('dashboard.nodes', 'Nodes')}
|
||||
value={serverList.length}
|
||||
subtitle={`${nodesOnline} online`}
|
||||
icon={Server}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.virtual_machines', 'Virtual Machines')}
|
||||
value={qemuVMs.length}
|
||||
subtitle={`${runningVMs} running`}
|
||||
icon={Monitor}
|
||||
color="text-blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.containers', 'Containers')}
|
||||
value={lxcCTs.length}
|
||||
subtitle={`${runningCTs} running`}
|
||||
icon={Container}
|
||||
color="text-green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.alerts', 'Alerts')}
|
||||
value={alerts?.count ?? 0}
|
||||
icon={AlertTriangle}
|
||||
color="text-amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<ResourceCard
|
||||
label="CPU"
|
||||
percent={cpuPercent}
|
||||
used={cpuUsedLabel}
|
||||
total={cpuTotalLabel}
|
||||
icon={Cpu}
|
||||
/>
|
||||
<ResourceCard
|
||||
label={t('dashboard.memory', 'Memory')}
|
||||
percent={memPercent}
|
||||
used={memUsedLabel}
|
||||
total={memTotalLabel}
|
||||
icon={MemoryStick}
|
||||
/>
|
||||
<ResourceCard
|
||||
label={t('dashboard.storage', 'Storage')}
|
||||
percent={diskPercent}
|
||||
used={diskUsedLabel}
|
||||
total={diskTotalLabel}
|
||||
icon={HardDrive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Recent instances */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">{t('dashboard.instances', 'Instances')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{allVMs.slice(0, 6).map((vm, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
vm.status === 'running' ? 'bg-green' : 'bg-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{String(vm.name ?? `VM ${vm.vmid}`)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{String(vm.type ?? 'vm').toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{String(vm.node ?? '')}</span>
|
||||
</div>
|
||||
))}
|
||||
{allVMs.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity */}
|
||||
<ActivityFeed items={(recentLogs?.items ?? []).map((log) => {
|
||||
const d = new Date(log.timestamp);
|
||||
return {
|
||||
time: `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`,
|
||||
user: log.username,
|
||||
action: log.message,
|
||||
type: log.level === 'ERROR' ? 'error' : log.level === 'WARNING' ? 'warning' : log.category === 'auth' ? 'info' : 'success',
|
||||
};
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
388
frontend/src/features/instances/CreateInstanceWizard.tsx
Normal file
388
frontend/src/features/instances/CreateInstanceWizard.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor } from 'lucide-react';
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const STEPS = ['server', 'template', 'config', 'confirm'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const handleClose = onClose ?? (() => navigate(-1));
|
||||
const [step, setStep] = useState<Step>('server');
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
// State
|
||||
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
|
||||
const [config, setConfig] = useState<{
|
||||
name: string;
|
||||
cores: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
storage: string;
|
||||
bridge: string;
|
||||
ipam_network_id: number | null;
|
||||
ip_address: string;
|
||||
gateway: string;
|
||||
start_after_create: boolean;
|
||||
cloud_init_user: string;
|
||||
cloud_init_password: string;
|
||||
ssh_keys: string;
|
||||
}>({
|
||||
name: '',
|
||||
cores: 2,
|
||||
memory: 2048,
|
||||
disk: 30,
|
||||
storage: 'local-lvm',
|
||||
bridge: 'vmbr0',
|
||||
ipam_network_id: null,
|
||||
ip_address: '',
|
||||
gateway: '',
|
||||
start_after_create: true,
|
||||
cloud_init_user: '',
|
||||
cloud_init_password: '',
|
||||
ssh_keys: '',
|
||||
});
|
||||
|
||||
// Queries
|
||||
const { data: servers = [] } = useServers();
|
||||
const { data: groups = [] } = useTemplateGroups();
|
||||
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
|
||||
const deploy = useMutation({
|
||||
mutationFn: (data: VMDeployRequest) => apiClient.post<{ success: boolean; vmid: number; name: string }>('/proxmox/api/deploy', data),
|
||||
onSuccess: () => {
|
||||
toast.success(t('wizard.deploy_success', { name: config.name }));
|
||||
handleClose();
|
||||
navigate('/instances');
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const next = () => {
|
||||
const i = STEPS.indexOf(step);
|
||||
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
|
||||
};
|
||||
const prev = () => {
|
||||
const i = STEPS.indexOf(step);
|
||||
if (i > 0) setStep(STEPS[i - 1]);
|
||||
};
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 'server') return !!selectedServer;
|
||||
if (step === 'template') return !!selectedTemplate;
|
||||
if (step === 'config') return config.name.length >= 2;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeploy = () => {
|
||||
if (!selectedTemplate) return;
|
||||
deploy.mutate({
|
||||
template_id: selectedTemplate.id,
|
||||
name: config.name,
|
||||
target_node: selectedNode || undefined,
|
||||
cores: config.cores,
|
||||
memory: config.memory,
|
||||
disk: config.disk,
|
||||
target_storage: config.storage,
|
||||
start_after_create: config.start_after_create,
|
||||
network_bridge: config.bridge,
|
||||
ip_address: config.ip_address || undefined,
|
||||
gateway: config.gateway || undefined,
|
||||
ipam_network_id: config.ipam_network_id || undefined,
|
||||
cloud_init_user: config.cloud_init_user || undefined,
|
||||
cloud_init_password: config.cloud_init_password || undefined,
|
||||
ssh_keys: config.ssh_keys || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Apply template defaults when template selected
|
||||
const selectTemplate = (tpl: OSTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
cores: tpl.default_cores,
|
||||
memory: tpl.default_memory,
|
||||
disk: tpl.default_disk,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
i < stepIdx ? 'bg-green-500/20 text-green-400' :
|
||||
i === stepIdx ? 'bg-blue-500/20 text-blue-400 ring-2 ring-blue-500/50' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{i < stepIdx ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`h-0.5 w-8 ${i < stepIdx ? 'bg-green-500/50' : 'bg-border'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Server */}
|
||||
{step === 'server' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.select_server')}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{servers.map(srv => (
|
||||
<Card
|
||||
key={srv.id}
|
||||
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedServer?.id === srv.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
|
||||
onClick={() => setSelectedServer(srv)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{srv.name}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">{srv.ip_address}:{srv.port}</p>
|
||||
</div>
|
||||
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
|
||||
{srv.is_online ? t('common.online') : t('common.offline')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Template */}
|
||||
{step === 'template' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
|
||||
{groups.map(group => {
|
||||
const groupTemplates = templates.filter(t => t.group_id === group.id);
|
||||
if (groupTemplates.length === 0) return null;
|
||||
return (
|
||||
<div key={group.id} className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{group.name}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groupTemplates.map(tpl => (
|
||||
<Card
|
||||
key={tpl.id}
|
||||
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedTemplate?.id === tpl.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
|
||||
onClick={() => selectTemplate(tpl)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{tpl.group_icon ? (
|
||||
<span className="text-lg" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
|
||||
) : (
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-sm truncate">{tpl.name}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{tpl.default_cores} vCPU · {tpl.default_memory} MB · {tpl.default_disk} GB
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{templates.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-8">{t('wizard.no_templates')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Configuration */}
|
||||
{step === 'config' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{/* Basic */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.basic')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder="my-vm-01" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.target_node')}</Label>
|
||||
<Input value={selectedNode} onChange={e => setSelectedNode(e.target.value)} placeholder={t('wizard.auto_select')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="start_after"
|
||||
checked={config.start_after_create}
|
||||
onChange={e => setConfig(p => ({ ...p, start_after_create: e.target.checked }))}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="start_after">{t('wizard.start_after_create')}</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resources */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Cpu className="h-4 w-4" />{t('wizard.resources')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>vCPU</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={selectedTemplate?.min_cores || 1}
|
||||
value={config.cores}
|
||||
onChange={e => setConfig(p => ({ ...p, cores: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={selectedTemplate?.min_memory || 256}
|
||||
step={256}
|
||||
value={config.memory}
|
||||
onChange={e => setConfig(p => ({ ...p, memory: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={selectedTemplate?.min_disk || 5}
|
||||
value={config.disk}
|
||||
onChange={e => setConfig(p => ({ ...p, disk: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.network')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('wizard.bridge')}</Label>
|
||||
<Input value={config.bridge} onChange={e => setConfig(p => ({ ...p, bridge: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>IPAM {t('wizard.network')}</Label>
|
||||
<Select value={String(config.ipam_network_id || '')} onValueChange={v => setConfig(p => ({ ...p, ipam_network_id: v ? Number(v) : null }))}>
|
||||
<SelectTrigger><SelectValue placeholder={t('wizard.manual_ip')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{networks.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!config.ipam_network_id && (
|
||||
<>
|
||||
<div>
|
||||
<Label>IP</Label>
|
||||
<Input value={config.ip_address} onChange={e => setConfig(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.10.10.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Gateway</Label>
|
||||
<Input value={config.gateway} onChange={e => setConfig(p => ({ ...p, gateway: e.target.value }))} placeholder="10.10.10.1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cloud-init */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm">Cloud-Init</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('wizard.ci_user')}</Label>
|
||||
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder="ubuntu" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.ci_password')}</Label>
|
||||
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>SSH Keys</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
|
||||
value={config.ssh_keys}
|
||||
onChange={e => setConfig(p => ({ ...p, ssh_keys: e.target.value }))}
|
||||
placeholder="ssh-rsa AAAA..."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Confirm */}
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
|
||||
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
|
||||
<Row label={t('common.name')} value={config.name} />
|
||||
<Row label="vCPU" value={String(config.cores)} />
|
||||
<Row label={t('wizard.memory_mb')} value={`${config.memory} MB`} />
|
||||
<Row label={t('wizard.disk_gb')} value={`${config.disk} GB`} />
|
||||
<Row label={t('wizard.bridge')} value={config.bridge} />
|
||||
{config.ipam_network_id && <Row label="IPAM" value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
|
||||
{config.ip_address && <Row label="IP" value={config.ip_address} />}
|
||||
{config.cloud_init_user && <Row label={t('wizard.ci_user')} value={config.cloud_init_user} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
|
||||
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
|
||||
</Button>
|
||||
{step === 'confirm' ? (
|
||||
<Button onClick={handleDeploy} disabled={deploy.isPending}>
|
||||
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('wizard.deploy')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={next} disabled={!canNext()}>
|
||||
{t('wizard.next')}<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/features/instances/InstanceDetailPage.tsx
Normal file
183
frontend/src/features/instances/InstanceDetailPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useParams, useSearchParams, Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Container,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
|
||||
import { vmTypeLabel } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import OverviewTab from './tabs/OverviewTab';
|
||||
import GraphsTab from './tabs/GraphsTab';
|
||||
import SnapshotsTab from './tabs/SnapshotsTab';
|
||||
import NetworkingTab from './tabs/NetworkingTab';
|
||||
import SettingsTab from './tabs/SettingsTab';
|
||||
import DestroyTab from './tabs/DestroyTab';
|
||||
|
||||
export default function InstanceDetailPage() {
|
||||
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const node = searchParams.get('node') || '';
|
||||
const type = searchParams.get('type') || 'qemu';
|
||||
const activeTab = searchParams.get('tab') || 'overview';
|
||||
|
||||
const sid = Number(serverId);
|
||||
const vid = Number(vmid);
|
||||
|
||||
// Find VM from cached list for basic info
|
||||
const { data: allVMs, isLoading } = useVirtualMachines();
|
||||
const vm = allVMs?.find((v) => v.server_id === sid && v.vmid === vid);
|
||||
|
||||
const power = usePowerAction(sid, vid, type, node);
|
||||
const isRunning = vm?.status === 'running';
|
||||
const TypeIcon = type === 'qemu' ? Monitor : Container;
|
||||
|
||||
const handlePower = (action: string) => {
|
||||
power.mutate(
|
||||
{ action },
|
||||
{
|
||||
onSuccess: () => toast.success(`${action} sent successfully`),
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setTab = (tab: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('tab', tab);
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back link + header */}
|
||||
<div>
|
||||
<Link to="/instances" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-3">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('nav.instances', 'Instances')}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<TypeIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-[22px] font-semibold">{vm?.name || `${vmTypeLabel(type)} ${vmid}`}</h1>
|
||||
<Badge variant="outline" className="text-xs">#{vmid}</Badge>
|
||||
<Badge variant="secondary" className="text-xs">{vmTypeLabel(type)}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot status={vm?.status || 'unknown'} pulse />
|
||||
{vm?.status || 'unknown'}
|
||||
</span>
|
||||
<span>Node: {node}</span>
|
||||
{vm?.ip_address && <span className="font-mono">{vm.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
render={<Link to={`/console/${serverId}/${vmid}?node=${node}&type=${type}`} target="_blank" />}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Terminal className="mr-1.5 h-4 w-4" />
|
||||
{t('common.console', 'Console')}
|
||||
</Button>
|
||||
|
||||
{!isRunning ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handlePower('start')}
|
||||
disabled={power.isPending}
|
||||
>
|
||||
<Play className="mr-1.5 h-4 w-4" />
|
||||
{t('common.start', 'Start')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePower('restart')}
|
||||
disabled={power.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-4 w-4" />
|
||||
{t('common.restart', 'Restart')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handlePower('stop')}
|
||||
disabled={power.isPending}
|
||||
>
|
||||
<Square className="mr-1.5 h-4 w-4" />
|
||||
{t('common.stop', 'Stop')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t('instances.overview', 'Overview')}</TabsTrigger>
|
||||
<TabsTrigger value="graphs">{t('instances.graphs', 'Graphs')}</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">{t('common.snapshots', 'Snapshots')}</TabsTrigger>
|
||||
<TabsTrigger value="networking">{t('instances.networking', 'Networking')}</TabsTrigger>
|
||||
<TabsTrigger value="settings">{t('nav.settings', 'Settings')}</TabsTrigger>
|
||||
<TabsTrigger value="destroy" className="text-destructive data-[state=active]:text-destructive">
|
||||
{t('instances.destroy', 'Destroy')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<OverviewTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
<TabsContent value="graphs" className="mt-4">
|
||||
<GraphsTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
<TabsContent value="snapshots" className="mt-4">
|
||||
<SnapshotsTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
<TabsContent value="networking" className="mt-4">
|
||||
<NetworkingTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<SettingsTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||
</TabsContent>
|
||||
<TabsContent value="destroy" className="mt-4">
|
||||
<DestroyTab serverId={sid} vmid={vid} type={type} node={node} name={vm?.name} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
598
frontend/src/features/instances/InstancesPage.tsx
Normal file
598
frontend/src/features/instances/InstancesPage.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Play,
|
||||
Square,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
Terminal,
|
||||
RotateCcw,
|
||||
Camera,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Monitor,
|
||||
Container,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { useVirtualMachines, useBulkOperation, usePowerAction } from '@/hooks/use-instances';
|
||||
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
|
||||
import type { VMInstance } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ==================== Inline Progress Bar ====================
|
||||
function InlineProgress({ value, max, className }: { value: number; max: number; className?: string }) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-16 overflow-hidden rounded-full bg-muted ${className}`}>
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Row Action Menu ====================
|
||||
function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
const { t } = useTranslation();
|
||||
const power = usePowerAction(vm.server_id!, vm.vmid, vm.type, vm.node);
|
||||
const isRunning = vm.status === 'running';
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
power.mutate(
|
||||
{ action },
|
||||
{
|
||||
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-8 w-8" />}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} />}>
|
||||
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{!isRunning ? (
|
||||
<DropdownMenuItem onClick={() => handleAction('start')}>
|
||||
<Play className="mr-2 h-4 w-4" /> {t('common.start', 'Start')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => handleAction('restart')}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" /> {t('common.restart', 'Restart')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('stop')}>
|
||||
<Square className="mr-2 h-4 w-4" /> {t('common.stop', 'Stop')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=snapshots`} />}>
|
||||
<Camera className="mr-2 h-4 w-4" /> {t('common.snapshots', 'Snapshots')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Instances Page ====================
|
||||
export default function InstancesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: vms, isLoading } = useVirtualMachines();
|
||||
const bulkOp = useBulkOperation();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [nodeFilter, setNodeFilter] = useState<string>('all');
|
||||
const [typeTab, setTypeTab] = useState<string>('all');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Filter data
|
||||
const filteredVMs = useMemo(() => {
|
||||
if (!vms) return [];
|
||||
return vms.filter((vm) => {
|
||||
// Type tab filter
|
||||
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
|
||||
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
|
||||
|
||||
// Node filter
|
||||
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const name = (vm.name || '').toLowerCase();
|
||||
const ip = (vm.ip_address || '').toLowerCase();
|
||||
const vmid = String(vm.vmid);
|
||||
if (!name.includes(q) && !ip.includes(q) && !vmid.includes(q)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [vms, typeTab, statusFilter, nodeFilter, search]);
|
||||
|
||||
// Unique nodes for filter
|
||||
const uniqueNodes = useMemo(() => {
|
||||
if (!vms) return [];
|
||||
return [...new Set(vms.map((v) => v.node))].sort();
|
||||
}, [vms]);
|
||||
|
||||
// Counts for tabs
|
||||
const counts = useMemo(() => {
|
||||
if (!vms) return { all: 0, vm: 0, lxc: 0 };
|
||||
return {
|
||||
all: vms.length,
|
||||
vm: vms.filter((v) => v.type === 'qemu').length,
|
||||
lxc: vms.filter((v) => v.type === 'lxc').length,
|
||||
};
|
||||
}, [vms]);
|
||||
|
||||
// Selected VMs
|
||||
const selectedVMs = useMemo(() => {
|
||||
return Object.keys(rowSelection)
|
||||
.filter((k) => rowSelection[k])
|
||||
.map((idx) => filteredVMs[Number(idx)])
|
||||
.filter(Boolean);
|
||||
}, [rowSelection, filteredVMs]);
|
||||
|
||||
// Bulk action handler
|
||||
const handleBulk = (action: string) => {
|
||||
if (!selectedVMs.length) return;
|
||||
bulkOp.mutate(
|
||||
{
|
||||
action,
|
||||
items: selectedVMs.map((vm) => ({
|
||||
server_id: vm.server_id!,
|
||||
vmid: vm.vmid,
|
||||
vm_type: vm.type,
|
||||
name: vm.name || String(vm.vmid),
|
||||
node: vm.node,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`);
|
||||
setRowSelection({});
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== Table Columns ====================
|
||||
const columns = useMemo<ColumnDef<VMInstance>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
{t('common.name', 'Name')}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
return (
|
||||
<Link
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
className="group flex items-center gap-2"
|
||||
>
|
||||
<StatusDot status={vm.status} pulse />
|
||||
<div>
|
||||
<span className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{vm.name || `VM ${vm.vmid}`}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">#{vm.vmid}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('common.status', 'Status'),
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue<string>();
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
status === 'running'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'node',
|
||||
header: t('common.node', 'Node'),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground">{getValue<string>()}</span>
|
||||
),
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: t('common.type', 'Type'),
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue<string>();
|
||||
const Icon = type === 'qemu' ? Monitor : Container;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span />}>
|
||||
<Badge variant="outline" className="gap-1 font-normal">
|
||||
<Icon className="h-3 w-3" />
|
||||
{vmTypeLabel(type)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip_address',
|
||||
header: 'IP',
|
||||
cell: ({ getValue }) => {
|
||||
const ip = getValue<string>();
|
||||
return ip ? (
|
||||
<span className="font-mono text-xs">{ip}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
size: 130,
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: 'CPU',
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (!vm.cpu && vm.cpu !== 0) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return <InlineProgress value={vm.cpu * 100} max={100} />;
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: t('dashboard.memory', 'Memory'),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (!vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InlineProgress value={vm.mem} max={vm.maxmem} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'disk',
|
||||
header: 'Disk',
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (!vm.disk && !vm.maxdisk) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InlineProgress value={vm.disk || 0} max={vm.maxdisk || 0} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'uptime',
|
||||
header: 'Uptime',
|
||||
cell: ({ row }) => {
|
||||
const up = row.original.uptime;
|
||||
return <span className="text-xs text-muted-foreground">{up ? formatUptime(up) : '—'}</span>;
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
accessorKey: 'tags',
|
||||
header: 'Tags',
|
||||
cell: ({ getValue }) => {
|
||||
const tags = getValue<string>();
|
||||
if (!tags) return null;
|
||||
return (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{tags.split(/[;,]/).filter(Boolean).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">
|
||||
{tag.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <RowActionMenu vm={row.original} />,
|
||||
size: 48,
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
// ==================== TanStack Table ====================
|
||||
const table = useReactTable({
|
||||
data: filteredVMs,
|
||||
columns,
|
||||
state: { sorting, rowSelection },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 25 } },
|
||||
});
|
||||
|
||||
// ==================== Render ====================
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-[22px] font-semibold">{t('nav.instances', 'Instances')}</h1>
|
||||
<Button render={<Link to="/instances/create" />} size="sm">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
{t('instances.create', 'Create Instance')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Type Tabs */}
|
||||
<Tabs value={typeTab} onValueChange={setTypeTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
All <Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.all}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vm">
|
||||
<Monitor className="mr-1 h-3.5 w-3.5" /> VM
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.vm}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lxc">
|
||||
<Container className="mr-1 h-3.5 w-3.5" /> LXC
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.lxc}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Filters bar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('instances.search_placeholder', 'Search by name, IP, or VMID...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder={t('common.status', 'Status')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('instances.all_statuses', 'All statuses')}</SelectItem>
|
||||
<SelectItem value="running">{t('common.running', 'Running')}</SelectItem>
|
||||
<SelectItem value="stopped">{t('common.stopped', 'Stopped')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={nodeFilter} onValueChange={v => { if (v !== null) setNodeFilter(v); }}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder={t('common.node', 'Node')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('instances.all_nodes', 'All nodes')}</SelectItem>
|
||||
{uniqueNodes.map((n) => (
|
||||
<SelectItem key={n} value={n}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions toolbar */}
|
||||
{selectedVMs.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">{selectedVMs.length} selected</span>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulk('start')} disabled={bulkOp.isPending}>
|
||||
<Play className="mr-1 h-3 w-3" /> Start
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulk('stop')} disabled={bulkOp.isPending}>
|
||||
<Square className="mr-1 h-3 w-3" /> Stop
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleBulk('delete')} disabled={bulkOp.isPending}>
|
||||
<Trash2 className="mr-1 h-3 w-3" /> Delete
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRowSelection({})}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredVMs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Monitor className="mb-3 h-10 w-10 opacity-40" />
|
||||
<p className="text-sm font-medium">{t('instances.no_instances', 'No instances found')}</p>
|
||||
<p className="text-xs">{search ? t('instances.try_other_search', 'Try a different search query') : t('instances.create_first', 'Create your first instance')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} style={{ width: header.getSize() }}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredVMs.length > 0 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t('instances.showing', 'Showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
||||
filteredVMs.length
|
||||
)}{' '}
|
||||
{t('instances.of', 'of')} {filteredVMs.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-2 tabular-nums">
|
||||
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/features/instances/tabs/DestroyTab.tsx
Normal file
83
frontend/src/features/instances/tabs/DestroyTab.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useDeleteVM } from '@/hooks/use-instances';
|
||||
import { vmTypeLabel } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function DestroyTab({ serverId, vmid, type, node, name }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const deleteVM = useDeleteVM(serverId, vmid, type, node);
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
|
||||
const displayName = name || `${vmTypeLabel(type)} ${vmid}`;
|
||||
const canDelete = confirmation === displayName;
|
||||
|
||||
const handleDestroy = () => {
|
||||
if (!canDelete) return;
|
||||
deleteVM.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(`${displayName} has been destroyed`);
|
||||
navigate('/instances');
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Destroy {vmTypeLabel(type)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<p className="font-medium">This action is irreversible!</p>
|
||||
<p className="mt-1 text-destructive/80">
|
||||
This will permanently delete <strong>{displayName}</strong> (#{vmid}) and all its data,
|
||||
including disks and snapshots. IP allocations will be released.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirm-name">
|
||||
Type <strong className="text-foreground">{displayName}</strong> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-name"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
placeholder={displayName}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDestroy}
|
||||
disabled={!canDelete || deleteVM.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{deleteVM.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Permanently Destroy {vmTypeLabel(type)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/features/instances/tabs/GraphsTab.tsx
Normal file
188
frontend/src/features/instances/tabs/GraphsTab.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useVMRrddata } from '@/hooks/use-instances';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
const TIMEFRAMES = [
|
||||
{ value: 'hour', label: '1h' },
|
||||
{ value: 'day', label: '24h' },
|
||||
{ value: 'week', label: '7d' },
|
||||
{ value: 'month', label: '30d' },
|
||||
{ value: 'year', label: '1y' },
|
||||
];
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
data: Array<Record<string, unknown>>;
|
||||
dataKey: string;
|
||||
color: string;
|
||||
formatValue?: (v: number) => string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTime}
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
|
||||
width={50}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(val: any) => [
|
||||
val != null && typeof val === 'number' && formatValue ? formatValue(val) : `${val ?? ''}${unit || ''}`,
|
||||
title,
|
||||
]}
|
||||
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [timeframe, setTimeframe] = useState('hour');
|
||||
const { data: rrdData, isLoading } = useVMRrddata(serverId, vmid, type, node, timeframe);
|
||||
|
||||
// Parse RRD data into chart-friendly format
|
||||
const chartData = (rrdData as Array<Record<string, unknown>> | undefined)?.map((point) => ({
|
||||
time: Number(point.time),
|
||||
cpu: (Number(point.cpu) || 0) * 100,
|
||||
mem: Number(point.mem) || Number(point.memused) || 0,
|
||||
maxmem: Number(point.maxmem) || 0,
|
||||
netin: Number(point.netin) || 0,
|
||||
netout: Number(point.netout) || 0,
|
||||
diskread: Number(point.diskread) || 0,
|
||||
diskwrite: Number(point.diskwrite) || 0,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Timeframe selector */}
|
||||
<div className="flex gap-1">
|
||||
{TIMEFRAMES.map((tf) => (
|
||||
<Button
|
||||
key={tf.value}
|
||||
variant={timeframe === tf.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeframe(tf.value)}
|
||||
>
|
||||
{tf.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
title={t('graphs.cpu_usage')}
|
||||
data={chartData}
|
||||
dataKey="cpu"
|
||||
color="hsl(217, 91%, 60%)"
|
||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||
unit="%"
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.memory_usage')}
|
||||
data={chartData}
|
||||
dataKey="mem"
|
||||
color="hsl(142, 76%, 36%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.network_in')}
|
||||
data={chartData}
|
||||
dataKey="netin"
|
||||
color="hsl(262, 83%, 58%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.network_out')}
|
||||
data={chartData}
|
||||
dataKey="netout"
|
||||
color="hsl(25, 95%, 53%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.disk_read')}
|
||||
data={chartData}
|
||||
dataKey="diskread"
|
||||
color="hsl(199, 89%, 48%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.disk_write')}
|
||||
data={chartData}
|
||||
dataKey="diskwrite"
|
||||
color="hsl(0, 84%, 60%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/instances/tabs/NetworkingTab.tsx
Normal file
120
frontend/src/features/instances/tabs/NetworkingTab.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Globe, Wifi } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useVMInterfaces, useVMConfig } from '@/hooks/use-instances';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useVMStatus } from '@/hooks/use-instances';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
|
||||
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
|
||||
// Extract network configs from VM config (net0, net1, ...)
|
||||
const netDevices = config
|
||||
? Object.entries(config)
|
||||
.filter(([k]) => /^net\d+$/.test(k))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Traffic summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">Network In</p>
|
||||
<p className="text-xl font-bold tabular-nums">{status?.netin !== undefined ? formatBytes(status.netin) : '—'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">Network Out</p>
|
||||
<p className="text-xl font-bold tabular-nums">{status?.netout !== undefined ? formatBytes(status.netout) : '—'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Configured network devices */}
|
||||
{netDevices.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
Network Devices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{netDevices.map(([key, value]) => {
|
||||
const parts = String(value).split(',');
|
||||
const bridgePart = parts.find((p) => p.includes('bridge='));
|
||||
const bridge = bridgePart?.split('=')[1];
|
||||
const modelPart = parts[0]; // e.g. "virtio=XX:XX:XX:XX:XX:XX"
|
||||
const [model, mac] = modelPart?.includes('=') ? modelPart.split('=') : [modelPart, ''];
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{key}</Badge>
|
||||
<span className="text-sm font-medium">{model}</span>
|
||||
</div>
|
||||
{bridge && (
|
||||
<Badge variant="secondary" className="text-xs">{bridge}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mac && (
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">{mac}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Live interfaces */}
|
||||
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4" />
|
||||
Live Interfaces (Guest Agent)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{ifaces.interfaces.map((iface) => (
|
||||
<div key={iface.name} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">{iface.name}</span>
|
||||
{iface.mac && (
|
||||
<span className="font-mono text-xs text-muted-foreground">{iface.mac}</span>
|
||||
)}
|
||||
</div>
|
||||
{iface.ips && iface.ips.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{iface.ips.map((ip) => (
|
||||
<div key={ip.address} className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{ip.address}/{ip.prefix}</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{ip.type}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No addresses</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
frontend/src/features/instances/tabs/OverviewTab.tsx
Normal file
175
frontend/src/features/instances/tabs/OverviewTab.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
percent,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
percent?: number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const barColor =
|
||||
percent !== undefined
|
||||
? percent >= 90
|
||||
? 'bg-red-500'
|
||||
: percent >= 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary'
|
||||
: 'bg-primary';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold tabular-nums">{value}</p>
|
||||
{percent !== undefined && (
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-border/50 last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
||||
|
||||
const cpuPercent = status ? (status.cpu || 0) * 100 : 0;
|
||||
const memPercent = status && status.maxmem ? (status.mem / status.maxmem) * 100 : 0;
|
||||
const diskPercent = status && status.maxdisk ? (status.disk / status.maxdisk) * 100 : 0;
|
||||
|
||||
const primaryIP = ifaces?.interfaces
|
||||
?.flatMap((i) => i.ips || [])
|
||||
.find((ip) => ip.type === 'ipv4' && !ip.address.startsWith('127.'));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metric cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
value={formatPercent(cpuPercent)}
|
||||
subtitle={config?.cores ? `${config.cores} cores` : undefined}
|
||||
percent={cpuPercent}
|
||||
icon={Cpu}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Memory"
|
||||
value={status ? formatBytes(status.mem) : '—'}
|
||||
subtitle={status ? `of ${formatBytes(status.maxmem)}` : undefined}
|
||||
percent={memPercent}
|
||||
icon={MemoryStick}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk"
|
||||
value={status ? formatBytes(status.disk) : '—'}
|
||||
subtitle={status ? `of ${formatBytes(status.maxdisk)}` : undefined}
|
||||
percent={diskPercent}
|
||||
icon={HardDrive}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Uptime"
|
||||
value={status?.uptime ? formatUptime(status.uptime) : '—'}
|
||||
icon={Clock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Info card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
Instance Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InfoRow label="VMID" value={vmid} />
|
||||
<InfoRow label="Node" value={node} />
|
||||
<InfoRow label="Type" value={type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'} />
|
||||
<InfoRow label="vCPU" value={config?.cores} />
|
||||
<InfoRow label="Memory" value={config?.memory ? `${config.memory} MB` : undefined} />
|
||||
<InfoRow label="OS Type" value={config?.ostype as string} />
|
||||
<InfoRow label="Boot Order" value={config?.boot as string} />
|
||||
<InfoRow label="QEMU Agent" value={config?.agent ? 'Enabled' : 'Disabled'} />
|
||||
<InfoRow label="Start on Boot" value={config?.onboot ? 'Yes' : 'No'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
Network
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{primaryIP && <InfoRow label="Primary IP" value={primaryIP.address} />}
|
||||
{status?.netin !== undefined && (
|
||||
<InfoRow label="Network In" value={formatBytes(status.netin)} />
|
||||
)}
|
||||
{status?.netout !== undefined && (
|
||||
<InfoRow label="Network Out" value={formatBytes(status.netout)} />
|
||||
)}
|
||||
{config?.net0 && <InfoRow label="net0" value={String(config.net0)} />}
|
||||
|
||||
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Interfaces</p>
|
||||
{ifaces.interfaces.map((iface) => (
|
||||
<div key={iface.name} className="rounded-md border p-2 text-xs">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Wifi className="h-3 w-3 text-muted-foreground" />
|
||||
{iface.name}
|
||||
{iface.mac && <span className="text-muted-foreground font-mono">{iface.mac}</span>}
|
||||
</div>
|
||||
{iface.ips?.map((ip) => (
|
||||
<div key={ip.address} className="ml-5 text-muted-foreground font-mono">
|
||||
{ip.address}/{ip.prefix} ({ip.type})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
frontend/src/features/instances/tabs/SettingsTab.tsx
Normal file
172
frontend/src/features/instances/tabs/SettingsTab.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useVMConfig, useUpdateConfig, useResizeDisk } from '@/hooks/use-instances';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type);
|
||||
const resizeDisk = useResizeDisk(serverId, vmid, type);
|
||||
|
||||
const [cores, setCores] = useState('');
|
||||
const [memory, setMemory] = useState('');
|
||||
const [diskSize, setDiskSize] = useState('');
|
||||
const [diskDevice, setDiskDevice] = useState('scsi0');
|
||||
|
||||
// Initialize from config
|
||||
const currentCores = config?.cores || 0;
|
||||
const currentMemory = config?.memory || 0;
|
||||
|
||||
const handleConfigUpdate = () => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
|
||||
if (memory && Number(memory) !== currentMemory) updates.memory = Number(memory);
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
toast.info('No changes to apply');
|
||||
return;
|
||||
}
|
||||
|
||||
updateConfig.mutate(updates, {
|
||||
onSuccess: () => {
|
||||
toast.success('Configuration updated');
|
||||
setCores('');
|
||||
setMemory('');
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDiskResize = () => {
|
||||
if (!diskSize) return;
|
||||
resizeDisk.mutate(
|
||||
{ disk: diskDevice, size: `${diskSize}G` },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Disk resized to ${diskSize}G`);
|
||||
setDiskSize('');
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* CPU & Memory */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Compute Resources
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="cores">vCPU Cores</Label>
|
||||
<Input
|
||||
id="cores"
|
||||
type="number"
|
||||
min={1}
|
||||
max={128}
|
||||
placeholder={String(currentCores)}
|
||||
value={cores}
|
||||
onChange={(e) => setCores(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Current: {currentCores} cores</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="memory">Memory (MB)</Label>
|
||||
<Input
|
||||
id="memory"
|
||||
type="number"
|
||||
min={128}
|
||||
step={128}
|
||||
placeholder={String(currentMemory)}
|
||||
value={memory}
|
||||
onChange={(e) => setMemory(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Current: {currentMemory} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleConfigUpdate} disabled={updateConfig.isPending} size="sm">
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Apply Changes
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: VM may need to be restarted for changes to take effect.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Resize */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Disk Resize</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="disk-device">Disk Device</Label>
|
||||
<Input
|
||||
id="disk-device"
|
||||
value={diskDevice}
|
||||
onChange={(e) => setDiskDevice(e.target.value)}
|
||||
placeholder="scsi0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="disk-size">New Size (GB)</Label>
|
||||
<Input
|
||||
id="disk-size"
|
||||
type="number"
|
||||
min={1}
|
||||
value={diskSize}
|
||||
onChange={(e) => setDiskSize(e.target.value)}
|
||||
placeholder="e.g. 100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDiskResize}
|
||||
disabled={!diskSize || resizeDisk.isPending}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{resizeDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Resize Disk
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disks can only be enlarged. Use the full target size.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Raw Config */}
|
||||
{config && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">Raw Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="overflow-auto rounded-md bg-muted p-3 text-xs font-mono max-h-64">
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
frontend/src/features/instances/tabs/SnapshotsTab.tsx
Normal file
263
frontend/src/features/instances/tabs/SnapshotsTab.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Loader2, Camera } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
useSnapshots,
|
||||
useCreateSnapshot,
|
||||
useDeleteSnapshot,
|
||||
useRollbackSnapshot,
|
||||
} from '@/hooks/use-instances';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { data, isLoading } = useSnapshots(serverId, vmid, type, node);
|
||||
const createSnap = useCreateSnapshot(serverId, vmid, type);
|
||||
const deleteSnap = useDeleteSnapshot(serverId, vmid, type, node);
|
||||
const rollbackSnap = useRollbackSnapshot(serverId, vmid, type, node);
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [snapName, setSnapName] = useState('');
|
||||
const [snapDesc, setSnapDesc] = useState('');
|
||||
const [includeRAM, setIncludeRAM] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [rollbackTarget, setRollbackTarget] = useState<string | null>(null);
|
||||
|
||||
const snapshots = data?.snapshots || [];
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!snapName.trim()) return;
|
||||
createSnap.mutate(
|
||||
{ snapname: snapName.trim(), description: snapDesc, vmstate: includeRAM },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Snapshot "${snapName}" created`);
|
||||
setShowCreate(false);
|
||||
setSnapName('');
|
||||
setSnapDesc('');
|
||||
setIncludeRAM(false);
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteSnap.mutate(deleteTarget, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Snapshot "${deleteTarget}" deleted`);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRollback = () => {
|
||||
if (!rollbackTarget) return;
|
||||
rollbackSnap.mutate(rollbackTarget, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Rolled back to "${rollbackTarget}"`);
|
||||
setRollbackTarget(null);
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{snapshots.length} snapshot{snapshots.length !== 1 ? 's' : ''}
|
||||
</h3>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Create Snapshot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Camera className="mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">No snapshots</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>RAM</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshots.map((snap) => (
|
||||
<TableRow key={snap.name}>
|
||||
<TableCell className="font-medium">{snap.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{snap.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm tabular-nums">
|
||||
{snap.snaptime
|
||||
? new Date(snap.snaptime * 1000).toLocaleString()
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{snap.vmstate ? (
|
||||
<Badge variant="secondary" className="text-[10px]">RAM</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setRollbackTarget(snap.name)}
|
||||
title="Rollback"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(snap.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Snapshot</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="snap-name">Name</Label>
|
||||
<Input
|
||||
id="snap-name"
|
||||
value={snapName}
|
||||
onChange={(e) => setSnapName(e.target.value)}
|
||||
placeholder="e.g. before-update"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="snap-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="snap-desc"
|
||||
value={snapDesc}
|
||||
onChange={(e) => setSnapDesc(e.target.value)}
|
||||
placeholder="Description"
|
||||
/>
|
||||
</div>
|
||||
{type === 'qemu' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="snap-ram"
|
||||
type="checkbox"
|
||||
checked={includeRAM}
|
||||
onChange={(e) => setIncludeRAM(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="snap-ram" className="text-sm">Include VM memory (RAM state)</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} disabled={!snapName.trim() || createSnap.isPending}>
|
||||
{createSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Snapshot</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete snapshot <strong>"{deleteTarget}"</strong>? This cannot be undone.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleteSnap.isPending}>
|
||||
{deleteSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rollback confirmation */}
|
||||
<Dialog open={!!rollbackTarget} onOpenChange={() => setRollbackTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rollback to Snapshot</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to rollback to <strong>"{rollbackTarget}"</strong>?
|
||||
Current state will be lost.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRollbackTarget(null)}>Cancel</Button>
|
||||
<Button onClick={handleRollback} disabled={rollbackSnap.isPending}>
|
||||
{rollbackSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/src/features/ipam/IPAMAllocationsPage.tsx
Normal file
110
frontend/src/features/ipam/IPAMAllocationsPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useIPAMAllocations, useIPAMNetworks, useDeleteAllocation } from '@/hooks/use-ipam';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
export default function IPAMAllocationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [networkId, setNetworkId] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
const { data: allocations = [] } = useIPAMAllocations({
|
||||
network_id: networkId !== 'all' ? Number(networkId) : undefined,
|
||||
limit: 200,
|
||||
});
|
||||
const deleteAllocation = useDeleteAllocation();
|
||||
|
||||
const filtered = allocations.filter(a => {
|
||||
if (statusFilter !== 'all' && a.status !== statusFilter) return false;
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
a.ip_address?.toLowerCase().includes(s) ||
|
||||
a.resource_name?.toLowerCase().includes(s) ||
|
||||
a.mac_address?.toLowerCase().includes(s) ||
|
||||
a.description?.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('ipam.allocations')}</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
<Select value={networkId} onValueChange={v => { if (v !== null) setNetworkId(v); }}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('ipam.network')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
{networks.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
<SelectItem value="allocated">Allocated</SelectItem>
|
||||
<SelectItem value="reserved">Reserved</SelectItem>
|
||||
<SelectItem value="available">Available</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead>{t('ipam.network')}</TableHead>
|
||||
<TableHead>{t('ipam.resource')}</TableHead>
|
||||
<TableHead>MAC</TableHead>
|
||||
<TableHead>{t('ipam.description')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono">{a.ip_address}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{a.network_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteAllocation.mutate(a.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/features/ipam/IPAMDashboardPage.tsx
Normal file
76
frontend/src/features/ipam/IPAMDashboardPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import { Network, Globe, AlertTriangle, Activity } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useIPAMSummary, useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
|
||||
export default function IPAMDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: summary } = useIPAMSummary();
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
|
||||
const s = summary as Record<string, number> | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SummaryCard icon={Globe} label={t('ipam.networks_count')} value={s?.networks_count ?? 0} to="/ipam/networks" />
|
||||
<SummaryCard icon={Network} label={t('ipam.total_allocated')} value={s?.total_allocated ?? 0} to="/ipam/allocations" />
|
||||
<SummaryCard icon={Activity} label={t('ipam.total_reserved')} value={s?.total_reserved ?? 0} />
|
||||
<SummaryCard icon={AlertTriangle} label={t('ipam.vm_count')} value={(s?.vm_count ?? 0) + (s?.lxc_count ?? 0)} />
|
||||
</div>
|
||||
|
||||
{/* Quick network overview */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t('ipam.networks')}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{networks.slice(0, 6).map(net => (
|
||||
<Link key={net.id} to={`/ipam/network/${net.id}`}>
|
||||
<Card className="hover:border-blue-500/50 transition-colors h-full">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{net.name}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{net.network}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(net.utilization_percent || 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0}</span>
|
||||
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{networks.length > 6 && (
|
||||
<Link to="/ipam/networks" className="text-sm text-blue-400 hover:underline">{t('ipam.view_all_networks')}</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ icon: Icon, label, value, to }: { icon: React.ElementType; label: string; value: number; to?: string }) {
|
||||
const content = (
|
||||
<Card className={to ? 'hover:border-blue-500/50 transition-colors' : ''}>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return to ? <Link to={to}>{content}</Link> : content;
|
||||
}
|
||||
92
frontend/src/features/ipam/IPAMHistoryPage.tsx
Normal file
92
frontend/src/features/ipam/IPAMHistoryPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, History } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useIPAMHistory, useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
|
||||
export default function IPAMHistoryPage() {
|
||||
const { t } = useTranslation();
|
||||
const [networkId, setNetworkId] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
const { data: history = [] } = useIPAMHistory(networkId !== 'all' ? { network_id: Number(networkId) } : undefined);
|
||||
|
||||
const filtered = history.filter(h => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
h.action?.toLowerCase().includes(s) ||
|
||||
h.ip_address?.toLowerCase().includes(s) ||
|
||||
h.username?.toLowerCase().includes(s) ||
|
||||
h.details?.toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
const actionColor = (action: string) => {
|
||||
if (action.includes('create') || action.includes('allocate')) return 'default';
|
||||
if (action.includes('delete') || action.includes('release')) return 'destructive';
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">{t('ipam.history')}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
<Select value={networkId} onValueChange={v => { if (v !== null) setNetworkId(v); }}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('ipam.network')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
{networks.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.date')}</TableHead>
|
||||
<TableHead>{t('ipam.action')}</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>{t('ipam.network')}</TableHead>
|
||||
<TableHead>{t('common.user')}</TableHead>
|
||||
<TableHead>{t('ipam.details')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((h, i) => (
|
||||
<TableRow key={h.id ?? i}>
|
||||
<TableCell className="text-xs whitespace-nowrap">{h.created_at ? new Date(h.created_at).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell><Badge variant={actionColor(h.action || '') as any}>{h.action}</Badge></TableCell>
|
||||
<TableCell className="font-mono text-xs">{h.ip_address || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{h.network_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{h.username || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[300px] truncate">{h.details || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
frontend/src/features/ipam/IPAMNetworkDetailPage.tsx
Normal file
153
frontend/src/features/ipam/IPAMNetworkDetailPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useIPAMNetwork, useIPAMNetworkStats, useIPAMAllocations, useIPAMPools } from '@/hooks/use-ipam';
|
||||
|
||||
export default function IPAMNetworkDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const nid = Number(id);
|
||||
const { data: network } = useIPAMNetwork(nid);
|
||||
const { data: stats } = useIPAMNetworkStats(nid);
|
||||
const { data: pools = [] } = useIPAMPools(nid);
|
||||
const { data: allocations = [] } = useIPAMAllocations({ network_id: nid, limit: 100 });
|
||||
|
||||
if (!network) return <div className="py-12 text-center text-muted-foreground">{t('common.loading')}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button render={<Link to="/ipam/networks" />} variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{network.name}</h1>
|
||||
<p className="text-sm text-muted-foreground font-mono">{network.network}</p>
|
||||
</div>
|
||||
<Badge variant={network.is_active ? 'default' : 'secondary'} className="ml-auto">{network.is_active ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<StatCard label={t('ipam.total_ips')} value={stats?.total_ips ?? network.total_ips ?? 0} />
|
||||
<StatCard label={t('ipam.allocated')} value={stats?.allocated_ips ?? network.used_ips ?? 0} />
|
||||
<StatCard label={t('ipam.reserved')} value={stats?.reserved_ips ?? 0} />
|
||||
<StatCard label={t('ipam.available')} value={stats?.available_ips ?? network.available_ips ?? 0} />
|
||||
</div>
|
||||
|
||||
{/* Utilization bar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{t('ipam.utilization')}</span>
|
||||
<span className="text-sm">{(stats?.utilization_percent ?? network.utilization_percent ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(stats?.utilization_percent ?? network.utilization_percent ?? 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network info */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('ipam.network_info')}</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<InfoRow label="Gateway" value={network.gateway || '—'} />
|
||||
<InfoRow label="VLAN" value={network.vlan_id ? String(network.vlan_id) : '—'} />
|
||||
<InfoRow label="DNS" value={network.dns_primary || '—'} />
|
||||
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
|
||||
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
|
||||
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pools */}
|
||||
{pools.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('ipam.pools')} ({pools.length})</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.name')}</TableHead>
|
||||
<TableHead>{t('common.type')}</TableHead>
|
||||
<TableHead>{t('ipam.range')}</TableHead>
|
||||
<TableHead>{t('ipam.usage')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pools.map(pool => (
|
||||
<TableRow key={pool.id}>
|
||||
<TableCell className="font-medium">{pool.name}</TableCell>
|
||||
<TableCell><Badge variant="outline">{pool.pool_type}</Badge></TableCell>
|
||||
<TableCell className="font-mono text-xs">{pool.range_start} — {pool.range_end}</TableCell>
|
||||
<TableCell className="text-xs">{pool.used_ips ?? 0} / {pool.total_ips ?? 0}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Allocations */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('ipam.allocations')} ({allocations.length})</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead>{t('ipam.resource')}</TableHead>
|
||||
<TableHead>MAC</TableHead>
|
||||
<TableHead>{t('ipam.description')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{allocations.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono">{a.ip_address}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{a.description || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{allocations.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium font-mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/features/ipam/IPAMNetworksPage.tsx
Normal file
95
frontend/src/features/ipam/IPAMNetworksPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Globe, Search } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useIPAMNetworks, useCreateNetwork } from '@/hooks/use-ipam';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function IPAMNetworksPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', network: '', gateway: '', description: '' });
|
||||
|
||||
const { data: networks = [], isLoading } = useIPAMNetworks();
|
||||
const createNet = useCreateNetwork();
|
||||
|
||||
const filtered = networks.filter(n =>
|
||||
n.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
n.network.includes(search)
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
createNet.mutate(form, {
|
||||
onSuccess: () => { toast.success(t('ipam.network_created')); setDialogOpen(false); setForm({ name: '', network: '', gateway: '', description: '' }); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t('ipam.networks')}</h1>
|
||||
<Button onClick={() => setDialogOpen(true)}><Plus className="mr-2 h-4 w-4" />{t('ipam.add_network')}</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-center py-12 text-muted-foreground">{t('common.loading')}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Globe className="mx-auto h-12 w-12 mb-3 opacity-50" />
|
||||
<p>{t('ipam.no_networks')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map(net => (
|
||||
<Link key={net.id} to={`/ipam/network/${net.id}`}>
|
||||
<Card className="hover:border-blue-500/50 transition-colors h-full">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">{net.name}</span>
|
||||
<Badge variant={net.is_active ? 'default' : 'secondary'}>{net.is_active ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-mono text-muted-foreground">{net.network}</p>
|
||||
{net.gateway && <p className="text-xs text-muted-foreground">GW: {net.gateway}</p>}
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${Math.min(net.utilization_percent || 0, 100)}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0} {t('ipam.ips_used')}</span>
|
||||
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
{net.server_name && <p className="text-xs text-muted-foreground">{net.server_name}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('ipam.add_network')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>{t('common.name')}</Label><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
|
||||
<div><Label>CIDR</Label><Input value={form.network} onChange={e => setForm(p => ({ ...p, network: e.target.value }))} placeholder="10.0.0.0/24" /></div>
|
||||
<div><Label>Gateway</Label><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} placeholder="10.0.0.1" /></div>
|
||||
<div><Label>{t('ipam.description')}</Label><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></div>
|
||||
<Button onClick={handleCreate} disabled={!form.name || !form.network || createNet.isPending} className="w-full">{t('common.create')}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/features/nodes/NodeDetailPage.tsx
Normal file
87
frontend/src/features/nodes/NodeDetailPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServer, useNodes } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
|
||||
export default function NodeDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId } = useParams<{ serverId: string }>();
|
||||
const sid = Number(serverId);
|
||||
const { data: server } = useServer(sid);
|
||||
const { data: nodesData } = useNodes(sid);
|
||||
const { data: allVMs = [] } = useVirtualMachines();
|
||||
|
||||
const nodes = nodesData?.nodes || [];
|
||||
const serverVMs = allVMs.filter(v => v.server_id === sid);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button render={<Link to="/nodes" />} variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{server?.name || `Server #${sid}`}</h1>
|
||||
<p className="text-sm text-muted-foreground font-mono">{server?.ip_address}:{server?.port}</p>
|
||||
</div>
|
||||
<Badge variant={server?.is_online ? 'default' : 'destructive'} className="ml-auto">
|
||||
{server?.is_online ? t('common.online') : t('common.offline')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Nodes in cluster */}
|
||||
{nodes.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t('nodes.cluster_nodes')}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{nodes.map(node => (
|
||||
<Card key={node.node}>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{node.node}</span>
|
||||
<StatusDot status={node.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1"><Cpu className="h-3 w-3" />{formatPercent(node.cpu || 0)} CPU</div>
|
||||
<div className="flex items-center gap-1"><MemoryStick className="h-3 w-3" />{formatBytes(node.mem || 0)} / {formatBytes(node.maxmem || 0)}</div>
|
||||
<div className="flex items-center gap-1"><HardDrive className="h-3 w-3" />{formatBytes(node.disk || 0)} / {formatBytes(node.maxdisk || 0)}</div>
|
||||
<div className="flex items-center gap-1"><Clock className="h-3 w-3" />{formatUptime(node.uptime || 0)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VMs on this server */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
|
||||
<div className="rounded-lg border divide-y">
|
||||
{serverVMs.map(vm => (
|
||||
<Link
|
||||
key={`${vm.server_id}-${vm.vmid}`}
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<StatusDot status={vm.status} />
|
||||
<span className="font-medium min-w-0 truncate flex-1">{vm.name || `VM ${vm.vmid}`}</span>
|
||||
<Badge variant="outline" className="text-xs">{vm.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
|
||||
<span className="text-xs text-muted-foreground">#{vm.vmid}</span>
|
||||
<span className="text-xs text-muted-foreground">{vm.node}</span>
|
||||
</Link>
|
||||
))}
|
||||
{serverVMs.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/features/nodes/NodesPage.tsx
Normal file
74
frontend/src/features/nodes/NodesPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Monitor, Container } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
|
||||
export default function NodesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: servers = [], isLoading } = useServers();
|
||||
const { data: vms = [] } = useVirtualMachines();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center py-20 text-muted-foreground">{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{servers.map(srv => {
|
||||
const srvVMs = vms.filter(v => v.server_id === srv.id && v.type === 'qemu');
|
||||
const srvCTs = vms.filter(v => v.server_id === srv.id && v.type === 'lxc');
|
||||
return (
|
||||
<Link key={srv.id} to={`/nodes/${srv.id}`}>
|
||||
<Card className="transition-colors hover:border-blue-500/50 h-full">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{srv.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{srv.ip_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
|
||||
{srv.is_online ? t('common.online') : t('common.offline')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
<span>{srvVMs.length} VM</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Container className="h-3.5 w-3.5" />
|
||||
<span>{srvCTs.length} LXC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{srv.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{srv.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Server className="mx-auto h-12 w-12 mb-3 opacity-50" />
|
||||
<p>{t('nodes.no_servers')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/features/settings/SettingsPage.tsx
Normal file
221
frontend/src/features/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useProfile, useUpdateProfile, useChangePassword,
|
||||
usePanelSettings, useUpdatePanelSettings,
|
||||
useSecuritySettings, useUpdateSecuritySettings,
|
||||
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
|
||||
useAppVersion, useCheckUpdates,
|
||||
} from '@/hooks/use-settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('nav.settings')}</h1>
|
||||
<Tabs defaultValue="profile">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
|
||||
<TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>
|
||||
<TabsTrigger value="security">{t('settings.security')}</TabsTrigger>
|
||||
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
|
||||
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="profile"><ProfileTab /></TabsContent>
|
||||
<TabsContent value="panel"><PanelTab /></TabsContent>
|
||||
<TabsContent value="security"><SecurityTab /></TabsContent>
|
||||
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
|
||||
<TabsContent value="about"><AboutTab /></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profile } = useProfile();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const changePassword = useChangePassword();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [currentPwd, setCurrentPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
|
||||
// Init from profile
|
||||
const inited = displayName || email;
|
||||
if (profile && !inited) {
|
||||
setDisplayName(profile.full_name || '');
|
||||
setEmail(profile.email || '');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.profile_info')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div><Label>{t('settings.username')}</Label><Input value={profile?.username || ''} disabled className="mt-1" /></div>
|
||||
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
|
||||
<Button size="sm" onClick={() => updateProfile.mutate({ full_name: displayName, email })}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.change_password')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div><Label>{t('settings.current_password')}</Label><Input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('settings.new_password')}</Label><Input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('settings.confirm_password')}</Label><Input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} className="mt-1" /></div>
|
||||
<Button size="sm" onClick={() => {
|
||||
if (newPwd !== confirmPwd) { toast.error(t('settings.password_mismatch')); return; }
|
||||
changePassword.mutate({ current_password: currentPwd, new_password: newPwd, confirm_password: confirmPwd });
|
||||
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
|
||||
}}>{t('settings.change_password')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = usePanelSettings();
|
||||
const updatePanel = useUpdatePanelSettings();
|
||||
const [panelName, setPanelName] = useState('');
|
||||
const [lang, setLang] = useState('');
|
||||
|
||||
if (settings && !panelName) {
|
||||
setPanelName(settings.panel_name || '');
|
||||
setLang(settings.language || 'ru');
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.panel_settings')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div><Label>{t('settings.panel_name')}</Label><Input value={panelName} onChange={e => setPanelName(e.target.value)} className="mt-1" /></div>
|
||||
<div>
|
||||
<Label>{t('settings.language')}</Label>
|
||||
<select value={lang} onChange={e => setLang(e.target.value)} className="mt-1 block w-full rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => updatePanel.mutate({ panel_name: panelName, language: lang })}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: security } = useSecuritySettings();
|
||||
const updateSecurity = useUpdateSecuritySettings();
|
||||
const [maxAttempts, setMaxAttempts] = useState('');
|
||||
const [lockoutDuration, setLockoutDuration] = useState('');
|
||||
const [sessionTimeout, setSessionTimeout] = useState('');
|
||||
|
||||
if (security && !maxAttempts) {
|
||||
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
||||
setLockoutDuration(String(security.lockout_duration ?? 300));
|
||||
setSessionTimeout(String(security.session_timeout ?? 3600));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.security_settings')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div><Label>{t('settings.max_attempts')}</Label><Input type="number" value={maxAttempts} onChange={e => setMaxAttempts(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('settings.lockout_duration')}</Label><Input type="number" value={lockoutDuration} onChange={e => setLockoutDuration(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
|
||||
<div><Label>{t('settings.session_timeout')}</Label><Input type="number" value={sessionTimeout} onChange={e => setSessionTimeout(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
|
||||
<Button size="sm" onClick={() => updateSecurity.mutate({
|
||||
max_login_attempts: Number(maxAttempts),
|
||||
lockout_duration: Number(lockoutDuration),
|
||||
session_timeout: Number(sessionTimeout),
|
||||
})}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: channels } = useNotificationChannels();
|
||||
const updateSmtp = useUpdateSMTP();
|
||||
const updateTg = useUpdateTelegram();
|
||||
const [smtpHost, setSmtpHost] = useState('');
|
||||
const [smtpPort, setSmtpPort] = useState('');
|
||||
const [smtpUser, setSmtpUser] = useState('');
|
||||
const [smtpPass, setSmtpPass] = useState('');
|
||||
const [smtpFrom, setSmtpFrom] = useState('');
|
||||
const [tgToken, setTgToken] = useState('');
|
||||
const [tgChatId, setTgChatId] = useState('');
|
||||
|
||||
if (channels?.smtp && !smtpHost) {
|
||||
setSmtpHost(String(channels.smtp.host ?? ''));
|
||||
setSmtpPort(String(channels.smtp.port ?? 587));
|
||||
setSmtpUser(String(channels.smtp.username ?? ''));
|
||||
setSmtpFrom(String(channels.smtp.from_email ?? ''));
|
||||
}
|
||||
if (channels?.telegram && !tgToken) {
|
||||
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
|
||||
setTgChatId(String(channels.telegram.chat_id ?? ''));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">SMTP</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div><Label>Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Port</Label><Input type="number" value={smtpPort} onChange={e => setSmtpPort(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Username</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Password</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>From Email</Label><Input value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => updateSmtp.mutate({ host: smtpHost, port: Number(smtpPort), username: smtpUser, password: smtpPass || undefined, from_email: smtpFrom })}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">Telegram</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div><Label>Bot Token</Label><Input value={tgToken} onChange={e => setTgToken(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
|
||||
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken })}>{t('common.save')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: version } = useAppVersion();
|
||||
const checkUpdates = useCheckUpdates();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
|
||||
<Badge variant="outline">{version?.version || '—'}</Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()}>
|
||||
{t('settings.check_updates')}
|
||||
</Button>
|
||||
{checkUpdates.data && (
|
||||
<p className="text-sm">{checkUpdates.data.has_update ? `${t('settings.update_available')}: ${checkUpdates.data.latest_version}` : t('settings.up_to_date')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/tasks/TasksPage.tsx
Normal file
120
frontend/src/features/tasks/TasksPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, X, ClipboardList, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAllTasks, useCancelTask, useClearCompletedTasks } from '@/hooks/use-tasks';
|
||||
|
||||
export default function TasksPage() {
|
||||
const { t } = useTranslation();
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const { data: tasksData, refetch } = useAllTasks();
|
||||
const tasks = tasksData?.tasks ?? [];
|
||||
const cancelTask = useCancelTask();
|
||||
const clearCompleted = useClearCompletedTasks();
|
||||
|
||||
const filtered = tasks.filter(task => {
|
||||
if (statusFilter === 'all') return true;
|
||||
return task.status === statusFilter;
|
||||
});
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running': case 'in_progress': return 'default';
|
||||
case 'completed': case 'success': return 'secondary';
|
||||
case 'failed': case 'error': return 'destructive';
|
||||
case 'queued': case 'pending': return 'outline';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">{t('nav.tasks')}</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => clearCompleted.mutate()}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />{t('tasks.clear_completed')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
<SelectItem value="running">{t('tasks.running')}</SelectItem>
|
||||
<SelectItem value="queued">{t('tasks.queued')}</SelectItem>
|
||||
<SelectItem value="completed">{t('tasks.completed')}</SelectItem>
|
||||
<SelectItem value="failed">{t('tasks.failed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">{filtered.length} {t('tasks.tasks_count')}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead>{t('common.type')}</TableHead>
|
||||
<TableHead>{t('tasks.description')}</TableHead>
|
||||
<TableHead>{t('tasks.server')}</TableHead>
|
||||
<TableHead>{t('tasks.node')}</TableHead>
|
||||
<TableHead>{t('common.user')}</TableHead>
|
||||
<TableHead>{t('tasks.started')}</TableHead>
|
||||
<TableHead>{t('tasks.progress')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((task, i) => (
|
||||
<TableRow key={task.id ?? task.upid ?? i}>
|
||||
<TableCell><Badge variant={statusColor(task.status) as any}>{task.status}</Badge></TableCell>
|
||||
<TableCell className="text-xs font-mono">{task.type || String(task.task_type ?? '') || '—'}</TableCell>
|
||||
<TableCell className="text-xs max-w-[250px] truncate">{String(task.description ?? '') || task.upid || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.server_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.node || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.user || '—'}</TableCell>
|
||||
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(task.created_at).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{task.progress != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
<span>{Number(task.progress ?? 0)}%</span>
|
||||
</div>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(task.status === 'running' || task.status === 'queued' || task.status === 'in_progress') && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => cancelTask.mutate(Number(task.id ?? 0))}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/features/templates/TemplatesPage.tsx
Normal file
148
frontend/src/features/templates/TemplatesPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Monitor, Search, Download, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTemplates, useTemplateGroups, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string>('');
|
||||
const [serverFilter, setServerFilter] = useState<string>('');
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importServerId, setImportServerId] = useState<string>('');
|
||||
|
||||
const { data: groups = [] } = useTemplateGroups();
|
||||
const { data: templates = [], isLoading } = useTemplates(
|
||||
groupFilter ? Number(groupFilter) : undefined,
|
||||
serverFilter ? Number(serverFilter) : undefined,
|
||||
);
|
||||
const { data: servers = [] } = useServers();
|
||||
const autoImport = useAutoImportTemplates();
|
||||
const deleteTpl = useDeleteTemplate();
|
||||
|
||||
const filtered = templates.filter(tpl =>
|
||||
tpl.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleImport = () => {
|
||||
if (!importServerId) return;
|
||||
autoImport.mutate(Number(importServerId), {
|
||||
onSuccess: (res) => {
|
||||
toast.success(`${t('templates.imported')}: ${res.count}`);
|
||||
setImportDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
|
||||
<Button onClick={() => setImportDialogOpen(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={groupFilter} onValueChange={v => { if (v !== null) setGroupFilter(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_groups')}</SelectItem>
|
||||
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={serverFilter} onValueChange={v => { if (v !== null) setServerFilter(v); }}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('templates.all_servers')}</SelectItem>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Templates grid */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-16 text-muted-foreground">{t('common.loading')}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Monitor className="mx-auto h-12 w-12 mb-3 opacity-50" />
|
||||
<p>{t('templates.no_templates')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map(tpl => (
|
||||
<Card key={tpl.id} className="group">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{tpl.group_icon ? (
|
||||
<span className="text-xl" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
|
||||
) : (
|
||||
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{tpl.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
|
||||
onClick={() => { if (confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">{tpl.default_cores} vCPU</Badge>
|
||||
<Badge variant="outline" className="text-xs">{tpl.default_memory} MB</Badge>
|
||||
<Badge variant="outline" className="text-xs">{tpl.default_disk} GB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{tpl.server_name}</span>
|
||||
<span>VMID: {tpl.vmid}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('templates.auto_import')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Select value={importServerId} onValueChange={v => { if (v !== null) setImportServerId(v); }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleImport} disabled={!importServerId || autoImport.isPending} className="w-full">
|
||||
{autoImport.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('templates.import_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
frontend/src/features/users/UsersPage.tsx
Normal file
264
frontend/src/features/users/UsersPage.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Plus, Shield, Trash2, RotateCcw, Unlock } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useUsers, useCreateUser, useDeleteUser,
|
||||
useResetPassword, useUnlockUser,
|
||||
useRoles, useCreateRole, useDeleteRole,
|
||||
useSessions, useTerminateSession,
|
||||
} from '@/hooks/use-users';
|
||||
|
||||
export default function UsersPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('nav.users')}</h1>
|
||||
<Tabs defaultValue="users">
|
||||
<TabsList>
|
||||
<TabsTrigger value="users">{t('users.users')}</TabsTrigger>
|
||||
<TabsTrigger value="roles">{t('users.roles')}</TabsTrigger>
|
||||
<TabsTrigger value="sessions">{t('users.sessions')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="users"><UsersTab /></TabsContent>
|
||||
<TabsContent value="roles"><RolesTab /></TabsContent>
|
||||
<TabsContent value="sessions"><SessionsTab /></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTab() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('user');
|
||||
const { data: users = [] } = useUsers();
|
||||
const { data: roles = [] } = useRoles();
|
||||
const createUser = useCreateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
const resetPassword = useResetPassword();
|
||||
const unlockUser = useUnlockUser();
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return u.username?.toLowerCase().includes(s) || u.full_name?.toLowerCase().includes(s) || u.email?.toLowerCase().includes(s);
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
createUser.mutate({ username, password, full_name: displayName, email }, {
|
||||
onSuccess: () => { setOpen(false); setUsername(''); setPassword(''); setDisplayName(''); setEmail(''); setRole('user'); },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('users.create')}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('users.create')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>{t('settings.username')}</Label><Input value={username} onChange={e => setUsername(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('settings.new_password')}</Label><Input type="password" value={password} onChange={e => setPassword(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
|
||||
<div>
|
||||
<Label>{t('users.role')}</Label>
|
||||
<Select value={role} onValueChange={v => { if (v !== null) setRole(v); }}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map(r => <SelectItem key={r.id ?? r.name} value={r.name}>{r.name}</SelectItem>)}
|
||||
{roles.length === 0 && <>
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="user">user</SelectItem>
|
||||
</>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
|
||||
<Button onClick={handleCreate}>{t('common.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('settings.username')}</TableHead>
|
||||
<TableHead>{t('settings.display_name')}</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>{t('users.role')}</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead>{t('users.last_login')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map(u => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.username}</TableCell>
|
||||
<TableCell>{u.full_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{u.email || '—'}</TableCell>
|
||||
<TableCell><Badge variant="outline">{u.role_name || '—'}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.is_locked ? 'destructive' : u.is_active !== false ? 'default' : 'secondary'}>
|
||||
{u.is_locked ? t('users.locked') : u.is_active !== false ? t('users.active') : t('users.inactive')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{u.last_login ? new Date(u.last_login).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('users.reset_password')} onClick={() => resetPassword.mutate(u.id)}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{u.is_locked && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('users.unlock')} onClick={() => unlockUser.mutate(u.id)}>
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteUser.mutate(u.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesTab() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [roleName, setRoleName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const { data: roles = [] } = useRoles();
|
||||
const createRole = useCreateRole();
|
||||
const deleteRole = useDeleteRole();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">{t('users.roles')}</CardTitle>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('users.create_role')}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('users.create_role')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>{t('common.name')}</Label><Input value={roleName} onChange={e => setRoleName(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
|
||||
<Button onClick={() => { createRole.mutate({ name: roleName, display_name: roleName, description, permissions: [] }); setOpen(false); setRoleName(''); setDescription(''); }}>{t('common.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.name')}</TableHead>
|
||||
<TableHead>{t('ipam.description')}</TableHead>
|
||||
<TableHead>{t('users.permissions_count')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map(r => (
|
||||
<TableRow key={r.id ?? r.name}>
|
||||
<TableCell className="font-medium"><div className="flex items-center gap-2"><Shield className="h-4 w-4" />{r.name}</div></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{r.description || '—'}</TableCell>
|
||||
<TableCell>{r.permissions?.length ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
{!r.is_system && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteRole.mutate(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: sessions = [] } = useSessions();
|
||||
const terminateSession = useTerminateSession();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('users.active_sessions')}</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('settings.username')}</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>{t('users.user_agent')}</TableHead>
|
||||
<TableHead>{t('users.created')}</TableHead>
|
||||
<TableHead>{t('users.expires')}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map(s => (
|
||||
<TableRow key={s.id ?? s.session_id}>
|
||||
<TableCell className="font-medium">{s.username}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{s.ip_address || '—'}</TableCell>
|
||||
<TableCell className="text-xs max-w-[200px] truncate">{s.user_agent || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{s.created_at ? new Date(s.created_at).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell className="text-xs">{s.expires_at ? new Date(s.expires_at).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => terminateSession.mutate(s.id ?? s.session_id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
122
frontend/src/features/workspaces/WorkspacesPage.tsx
Normal file
122
frontend/src/features/workspaces/WorkspacesPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Search, Trash2, Users, Server, Pencil } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
|
||||
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace } from '@/hooks/use-workspaces';
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const { data: workspaces = [] } = useWorkspaces();
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const updateWorkspace = useUpdateWorkspace();
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
const filtered = workspaces.filter(w => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return w.name?.toLowerCase().includes(s) || w.description?.toLowerCase().includes(s);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t('nav.workspaces')}</h1>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('workspaces.create')}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('workspaces.create')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>{t('common.name')}</Label><Input value={name} onChange={e => setName(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
|
||||
<Button onClick={() => { createWorkspace.mutate({ name, description }); setOpen(false); setName(''); setDescription(''); }}>{t('common.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map(ws => (
|
||||
<Card key={ws.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base">{ws.name}</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
|
||||
setEditId(ws.id);
|
||||
setEditName(ws.name);
|
||||
setEditDescription(ws.description || '');
|
||||
setEditOpen(true);
|
||||
}}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteWorkspace.mutate(ws.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{ws.description && <p className="text-xs text-muted-foreground">{ws.description}</p>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1"><Server className="h-3.5 w-3.5" />{ws.servers?.length ?? 0} {t('workspaces.servers')}</div>
|
||||
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5" />{ws.users?.length ?? 0} {t('workspaces.users')}</div>
|
||||
</div>
|
||||
{ws.servers && ws.servers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ws.servers.map((s: any) => (
|
||||
<Badge key={s.id ?? s} variant="outline" className="text-xs">{s.name ?? s}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('workspaces.created')}: {ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '—'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="col-span-full text-center text-muted-foreground py-12">{t('common.no_data')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Workspace Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('workspaces.edit')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>{t('common.name')}</Label><Input value={editName} onChange={e => setEditName(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>{t('ipam.description')}</Label><Input value={editDescription} onChange={e => setEditDescription(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
|
||||
<Button onClick={() => {
|
||||
if (editId) {
|
||||
updateWorkspace.mutate({ id: editId, name: editName, description: editDescription });
|
||||
}
|
||||
setEditOpen(false);
|
||||
}}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/src/hooks/use-backups.ts
Normal file
71
frontend/src/hooks/use-backups.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export const backupKeys = {
|
||||
storages: (serverId: number) => ['backup-storages', serverId] as const,
|
||||
list: (serverId: number, node: string, storage: string) => ['backups', serverId, node, storage] as const,
|
||||
jobs: ['backup-jobs'] as const,
|
||||
proxmoxJobs: (serverId: number) => ['proxmox-backup-jobs', serverId] as const,
|
||||
};
|
||||
|
||||
export function useBackupStorages(serverId: number) {
|
||||
return useQuery({
|
||||
queryKey: backupKeys.storages(serverId),
|
||||
queryFn: () => apiClient.get<{ storages: unknown[] }>(`/proxmox/api/backups/storages/${serverId}`),
|
||||
enabled: serverId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackupList(serverId: number, node: string, storage: string) {
|
||||
return useQuery({
|
||||
queryKey: backupKeys.list(serverId, node, storage),
|
||||
queryFn: () => apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?node=${node}&storage=${storage}`),
|
||||
enabled: serverId > 0 && !!node && !!storage,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackupJobs() {
|
||||
return useQuery({
|
||||
queryKey: backupKeys.jobs,
|
||||
queryFn: () => apiClient.get<{ jobs: unknown[] }>('/proxmox/api/backups/jobs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBackup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { server_id: number; node: string; vmid: number; storage: string; mode?: string; compress?: string; notes?: string }) =>
|
||||
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/create', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBackup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { server_id: number; node: string; storage: string; volid: string }) =>
|
||||
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreBackup() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage: string; vm_type: string; new_vmid?: number; start?: boolean }) =>
|
||||
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/restore', data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleBackupJob() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (jobId: number) => apiClient.patch<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}/toggle`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunBackupJob() {
|
||||
return useMutation({
|
||||
mutationFn: (jobId: number) => apiClient.post<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}/run-now`),
|
||||
});
|
||||
}
|
||||
229
frontend/src/hooks/use-instances.ts
Normal file
229
frontend/src/hooks/use-instances.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { VMInstance, Snapshot, VMConfig } from '@/types';
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
export const vmKeys = {
|
||||
all: ['virtual-machines'] as const,
|
||||
resources: (serverId?: number) => ['resources', serverId] as const,
|
||||
resourcesAll: ['resources', 'all'] as const,
|
||||
detail: (serverId: number, vmid: number) => ['vm', serverId, vmid] as const,
|
||||
config: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'config'] as const,
|
||||
status: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'status'] as const,
|
||||
rrddata: (serverId: number, vmid: number, tf: string) => ['vm', serverId, vmid, 'rrddata', tf] as const,
|
||||
snapshots: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'snapshots'] as const,
|
||||
interfaces: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'interfaces'] as const,
|
||||
};
|
||||
|
||||
// ==================== VM List ====================
|
||||
export function useVirtualMachines() {
|
||||
return useQuery({
|
||||
queryKey: vmKeys.all,
|
||||
queryFn: () => apiClient.get<VMInstance[]>('/proxmox/api/virtual-machines'),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllResources() {
|
||||
return useQuery({
|
||||
queryKey: vmKeys.resourcesAll,
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== VM Detail ====================
|
||||
interface VMStatusResponse {
|
||||
status: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
maxmem: number;
|
||||
disk: number;
|
||||
maxdisk: number;
|
||||
uptime: number;
|
||||
netin: number;
|
||||
netout: number;
|
||||
name?: string;
|
||||
pid?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function useVMStatus(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery<VMStatusResponse>({
|
||||
queryKey: vmKeys.status(serverId, vmid),
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`),
|
||||
refetchInterval: 10000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery<VMConfig>({
|
||||
queryKey: vmKeys.config(serverId, vmid),
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/config?node=${node}`),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVMRrddata(serverId: number, vmid: number, type: string, node: string, timeframe = 'hour') {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery({
|
||||
queryKey: vmKeys.rrddata(serverId, vmid, timeframe),
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<{ data: unknown[]; timeframe: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/rrddata?node=${node}&timeframe=${timeframe}`);
|
||||
return res.data ?? [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface InterfaceInfo {
|
||||
name: string;
|
||||
mac?: string;
|
||||
ips?: Array<{ address: string; prefix: number; type: string }>;
|
||||
}
|
||||
|
||||
export function useVMInterfaces(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery<{ interfaces: InterfaceInfo[] }>({
|
||||
queryKey: vmKeys.interfaces(serverId, vmid),
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/interfaces?node=${node}`),
|
||||
enabled,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Snapshots ====================
|
||||
export function useSnapshots(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery<{ snapshots: Snapshot[] }>({
|
||||
queryKey: vmKeys.snapshots(serverId, vmid),
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots?node=${node}`),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSnapshot(serverId: number, vmid: number, type: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (body: { snapname: string; description?: string; vmstate?: boolean }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSnapshot(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (snapname: string) =>
|
||||
apiClient.delete(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots/${snapname}?node=${node}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRollbackSnapshot(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (snapname: string) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots/${snapname}/rollback?node=${node}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Power Actions ====================
|
||||
interface PowerActionResult {
|
||||
status: string;
|
||||
action: string;
|
||||
vmid: number;
|
||||
node: string;
|
||||
upid?: string;
|
||||
}
|
||||
|
||||
export function usePowerAction(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }>({
|
||||
mutationFn: ({ action, force }) => {
|
||||
const params = new URLSearchParams({ node });
|
||||
if (force) params.set('force', '1');
|
||||
return apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/${action}?${params}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Delete VM ====================
|
||||
export function useDeleteVM(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.delete(`/proxmox/api/${serverId}/${prefix}/${vmid}?node=${node}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Bulk Operations ====================
|
||||
interface BulkItem {
|
||||
server_id: number;
|
||||
vmid: number;
|
||||
vm_type: string;
|
||||
name: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
export function useBulkOperation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { action: string; items: BulkItem[] }) =>
|
||||
apiClient.post('/proxmox/api/bulk-operation', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Disk Resize ====================
|
||||
export function useResizeDisk(serverId: number, vmid: number, type: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (body: { disk: string; size: string }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/disk/resize`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Config Update ====================
|
||||
export function useUpdateConfig(serverId: number, vmid: number, type: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/config`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
131
frontend/src/hooks/use-ipam.ts
Normal file
131
frontend/src/hooks/use-ipam.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types';
|
||||
|
||||
export const ipamKeys = {
|
||||
networks: ['ipam-networks'] as const,
|
||||
network: (id: number) => ['ipam-network', id] as const,
|
||||
networkStats: (id: number) => ['ipam-network-stats', id] as const,
|
||||
ipMap: (id: number) => ['ipam-ip-map', id] as const,
|
||||
pools: (networkId?: number) => ['ipam-pools', networkId] as const,
|
||||
allocations: (params?: Record<string, unknown>) => ['ipam-allocations', params] as const,
|
||||
history: (params?: Record<string, unknown>) => ['ipam-history', params] as const,
|
||||
summary: ['ipam-summary'] as const,
|
||||
conflicts: ['ipam-conflicts'] as const,
|
||||
};
|
||||
|
||||
export function useIPAMNetworks() {
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.networks,
|
||||
queryFn: () => apiClient.get<IPAMNetwork[]>('/ipam/api/networks'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMNetwork(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.network(id),
|
||||
queryFn: () => apiClient.get<IPAMNetwork>(`/ipam/api/networks/${id}`),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMNetworkStats(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.networkStats(id),
|
||||
queryFn: () => apiClient.get<{ total_ips: number; allocated_ips: number; reserved_ips: number; available_ips: number; utilization_percent: number }>(`/ipam/api/networks/${id}/stats`),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMIPMap(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.ipMap(id),
|
||||
queryFn: () => apiClient.get<{ network_id: number; ip_map: unknown[] }>(`/ipam/api/networks/${id}/ip-map`),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMPools(networkId?: number) {
|
||||
const qs = networkId ? `?network_id=${networkId}` : '';
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.pools(networkId),
|
||||
queryFn: () => apiClient.get<IPAMPool[]>(`/ipam/api/pools${qs}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMAllocations(params?: { network_id?: number; pool_id?: number; status?: string; search?: string; limit?: number; offset?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.network_id) qs.set('network_id', String(params.network_id));
|
||||
if (params?.pool_id) qs.set('pool_id', String(params.pool_id));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.allocations(params),
|
||||
queryFn: () => apiClient.get<IPAMAllocation[]>(`/ipam/api/allocations?${qs}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMHistory(params?: { network_id?: number; ip_address?: string; action?: string; limit?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.network_id) qs.set('network_id', String(params.network_id));
|
||||
if (params?.ip_address) qs.set('ip_address', params.ip_address);
|
||||
if (params?.action) qs.set('action', params.action);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.history(params),
|
||||
queryFn: () => apiClient.get<any[]>(`/ipam/api/history?${qs}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMSummary() {
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.summary,
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>('/ipam/api/summary'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateNetwork() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<IPAMNetwork>) => apiClient.post<IPAMNetwork>('/ipam/api/networks', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNetwork() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: Partial<IPAMNetwork> & { id: number }) =>
|
||||
apiClient.put<IPAMNetwork>(`/ipam/api/networks/${id}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ipam-network'] });
|
||||
qc.invalidateQueries({ queryKey: ipamKeys.networks });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNetwork() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/ipam/api/networks/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAllocation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<IPAMAllocation>) => apiClient.post<IPAMAllocation>('/ipam/api/allocations', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAllocation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/ipam/api/allocations/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
|
||||
});
|
||||
}
|
||||
75
frontend/src/hooks/use-logs.ts
Normal file
75
frontend/src/hooks/use-logs.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { AuditLog } from '@/types';
|
||||
|
||||
export const logKeys = {
|
||||
logs: (params?: Record<string, unknown>) => ['logs', params] as const,
|
||||
stats: (hours?: number) => ['log-stats', hours] as const,
|
||||
levels: ['log-levels'] as const,
|
||||
categories: ['log-categories'] as const,
|
||||
};
|
||||
|
||||
interface LogsResponse {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
interface LogStats {
|
||||
period_hours: number;
|
||||
total: number;
|
||||
by_level: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
errors_count: number;
|
||||
failed_logins: number;
|
||||
recent_errors: unknown[];
|
||||
}
|
||||
|
||||
export function useLogs(params?: { level?: string; category?: string; username?: string; search?: string; page?: number; limit?: number; date_from?: string; date_to?: string }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.level) qs.set('level', params.level);
|
||||
if (params?.category) qs.set('category', params.category);
|
||||
if (params?.username) qs.set('username', params.username);
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.date_from) qs.set('date_from', params.date_from);
|
||||
if (params?.date_to) qs.set('date_to', params.date_to);
|
||||
return useQuery({
|
||||
queryKey: logKeys.logs(params),
|
||||
queryFn: () => apiClient.get<LogsResponse>(`/logs/api/logs?${qs}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogStats(hours = 24) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.stats(hours),
|
||||
queryFn: () => apiClient.get<LogStats>(`/logs/api/stats?hours=${hours}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogLevels() {
|
||||
return useQuery({
|
||||
queryKey: logKeys.levels,
|
||||
queryFn: () => apiClient.get<{ levels: { value: string; label: string; color: string }[] }>('/logs/api/levels'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogCategories() {
|
||||
return useQuery({
|
||||
queryKey: logKeys.categories,
|
||||
queryFn: () => apiClient.get<{ categories: { value: string; label: string }[] }>('/logs/api/categories'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOldLogs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (days: number) => apiClient.delete(`/logs/api/logs?days=${days}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['logs'] }),
|
||||
});
|
||||
}
|
||||
72
frontend/src/hooks/use-nodes.ts
Normal file
72
frontend/src/hooks/use-nodes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { ProxmoxServer, ProxmoxNode } from '@/types';
|
||||
|
||||
export const nodeKeys = {
|
||||
servers: ['servers'] as const,
|
||||
server: (id: number) => ['servers', id] as const,
|
||||
nodes: (serverId: number) => ['nodes', serverId] as const,
|
||||
nodeStatus: (serverId: number, node: string) => ['node-status', serverId, node] as const,
|
||||
nodeRrd: (serverId: number, node: string, tf: string) => ['node-rrd', serverId, node, tf] as const,
|
||||
clusterInfo: (serverId: number) => ['cluster-info', serverId] as const,
|
||||
topology: ['cluster-topology'] as const,
|
||||
storages: (serverId: number, node: string) => ['storages', serverId, node] as const,
|
||||
};
|
||||
|
||||
export function useServers() {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.servers,
|
||||
queryFn: () => apiClient.get<ProxmoxServer[]>('/proxmox/api/servers'),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServer(serverId: number) {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.server(serverId),
|
||||
queryFn: () => apiClient.get<ProxmoxServer>(`/proxmox/api/servers/${serverId}`),
|
||||
enabled: serverId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodes(serverId: number) {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.nodes(serverId),
|
||||
queryFn: () => apiClient.get<{ nodes: ProxmoxNode[]; cluster_id: string }>(`/proxmox/api/${serverId}/nodes`),
|
||||
enabled: serverId > 0,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeStatus(serverId: number, node: string) {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.nodeStatus(serverId, node),
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>(`/proxmox/api/${serverId}/node/status?node=${node}`),
|
||||
enabled: serverId > 0 && !!node,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNodeRrddata(serverId: number, node: string, timeframe = 'hour') {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.nodeRrd(serverId, node, timeframe),
|
||||
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/node/rrddata?node=${node}&timeframe=${timeframe}`),
|
||||
enabled: serverId > 0 && !!node,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClusterTopology() {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.topology,
|
||||
queryFn: () => apiClient.get<{ clusters: Record<string, unknown[]>; standalone: unknown[] }>('/proxmox/api/cluster/topology'),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStorages(serverId: number, node: string) {
|
||||
return useQuery({
|
||||
queryKey: nodeKeys.storages(serverId, node),
|
||||
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/storages?node=${node}`),
|
||||
enabled: serverId > 0 && !!node,
|
||||
});
|
||||
}
|
||||
77
frontend/src/hooks/use-notifications.ts
Normal file
77
frontend/src/hooks/use-notifications.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { Notification } from '@/types';
|
||||
|
||||
export const notificationKeys = {
|
||||
list: (params?: Record<string, unknown>) => ['notifications', params] as const,
|
||||
unreadCount: ['notifications-unread-count'] as const,
|
||||
preferences: ['notification-preferences'] as const,
|
||||
};
|
||||
|
||||
interface NotificationListResponse {
|
||||
total: number;
|
||||
unread_count: number;
|
||||
notifications: Notification[];
|
||||
}
|
||||
|
||||
export function useNotifications(params?: { unread_only?: boolean; level?: string; limit?: number; offset?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.unread_only) qs.set('unread_only', 'true');
|
||||
if (params?.level) qs.set('level', params.level);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.list(params),
|
||||
queryFn: () => apiClient.get<NotificationListResponse>(`/api/notifications/?${qs}`),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.unreadCount,
|
||||
queryFn: () => apiClient.get<{ count: number }>('/api/notifications/unread-count'),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.patch<Notification>(`/api/notifications/${id}/read`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<{ message: string }>('/api/notifications/mark-all-read'),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNotification() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/api/notifications/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAllRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.delete('/api/notifications/read/all'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
});
|
||||
}
|
||||
122
frontend/src/hooks/use-settings.ts
Normal file
122
frontend/src/hooks/use-settings.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export const settingsKeys = {
|
||||
profile: ['settings-profile'] as const,
|
||||
panel: ['settings-panel'] as const,
|
||||
security: ['settings-security'] as const,
|
||||
channels: ['notification-channels'] as const,
|
||||
version: ['app-version'] as const,
|
||||
};
|
||||
|
||||
interface ProfileResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
ssh_public_key?: string;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
interface PanelSettings {
|
||||
panel_name: string;
|
||||
log_retention_days: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export function useProfile() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.profile,
|
||||
queryFn: () => apiClient.get<ProfileResponse>('/settings/api/profile'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { full_name?: string; email?: string; ssh_public_key?: string }) =>
|
||||
apiClient.put<{ message: string }>('/settings/api/profile', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.profile }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { current_password: string; new_password: string; confirm_password: string }) =>
|
||||
apiClient.post<{ message: string }>('/settings/api/change-password', data),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePanelSettings() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.panel,
|
||||
queryFn: () => apiClient.get<PanelSettings>('/settings/api/panel'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePanelSettings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<PanelSettings>) =>
|
||||
apiClient.put<{ message: string }>('/settings/api/panel', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.panel }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSecuritySettings() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.security,
|
||||
queryFn: () => apiClient.get<{ single_session_enabled: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }>('/settings/api/security-settings'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSecuritySettings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { single_session_enabled?: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }) =>
|
||||
apiClient.put<{ message: string }>('/settings/api/security-settings', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.security }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationChannels() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.channels,
|
||||
queryFn: () => apiClient.get<{ smtp: Record<string, unknown>; telegram: Record<string, unknown> }>('/settings/api/notification-channels'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSMTP() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
apiClient.put<{ message: string }>('/settings/api/notification-channels/smtp', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.channels }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTelegram() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { telegram_bot_token?: string }) =>
|
||||
apiClient.put<{ message: string }>('/settings/api/notification-channels/telegram', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.channels }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppVersion() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.version,
|
||||
queryFn: () => apiClient.get<{ version: string }>('/settings/api/version'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCheckUpdates() {
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.get<Record<string, unknown>>('/settings/api/updates/check'),
|
||||
});
|
||||
}
|
||||
65
frontend/src/hooks/use-tasks.ts
Normal file
65
frontend/src/hooks/use-tasks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export const taskKeys = {
|
||||
all: (params?: Record<string, unknown>) => ['all-tasks', params] as const,
|
||||
activeCount: ['tasks-active-count'] as const,
|
||||
bulk: (limit?: number) => ['bulk-tasks', limit] as const,
|
||||
};
|
||||
|
||||
interface TaskItem {
|
||||
id?: number;
|
||||
upid?: string;
|
||||
type: string;
|
||||
status: string;
|
||||
action?: string;
|
||||
items_count?: number;
|
||||
completed_count?: number;
|
||||
failed_count?: number;
|
||||
created_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
node?: string;
|
||||
server_id?: number;
|
||||
server_name?: string;
|
||||
exitstatus?: string;
|
||||
starttime?: number;
|
||||
endtime?: number;
|
||||
user?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function useAllTasks(params?: { limit?: number; status?: string }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
return useQuery({
|
||||
queryKey: taskKeys.all(params),
|
||||
queryFn: () => apiClient.get<{ tasks: TaskItem[] }>(`/proxmox/api/all-tasks?${qs}`),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveTaskCount() {
|
||||
return useQuery({
|
||||
queryKey: taskKeys.activeCount,
|
||||
queryFn: () => apiClient.get<{ count: number }>('/proxmox/api/all-tasks/active-count'),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelTask() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (taskId: number) => apiClient.post<{ success: boolean }>(`/proxmox/api/tasks/${taskId}/cancel`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-tasks'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearCompletedTasks() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.delete('/proxmox/api/all-tasks/completed'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-tasks'] }),
|
||||
});
|
||||
}
|
||||
80
frontend/src/hooks/use-templates.ts
Normal file
80
frontend/src/hooks/use-templates.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { OSTemplate, OSTemplateGroup } from '@/types';
|
||||
|
||||
export const templateKeys = {
|
||||
groups: ['template-groups'] as const,
|
||||
templates: (groupId?: number, serverId?: number) => ['templates', groupId, serverId] as const,
|
||||
template: (id: number) => ['template', id] as const,
|
||||
discover: (serverId: number) => ['template-discover', serverId] as const,
|
||||
};
|
||||
|
||||
export function useTemplateGroups() {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.groups,
|
||||
queryFn: () => apiClient.get<OSTemplateGroup[]>('/templates/api/groups'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTemplates(groupId?: number, serverId?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (groupId) params.set('group_id', String(groupId));
|
||||
if (serverId) params.set('server_id', String(serverId));
|
||||
const qs = params.toString();
|
||||
return useQuery({
|
||||
queryKey: templateKeys.templates(groupId, serverId),
|
||||
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTemplate(templateId: number) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.template(templateId),
|
||||
queryFn: () => apiClient.get<OSTemplate>(`/templates/api/templates/${templateId}`),
|
||||
enabled: templateId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscoverTemplates(serverId: number) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.discover(serverId),
|
||||
queryFn: () => apiClient.get<{ server_id: number; server_name: string; templates: unknown[] }>(`/templates/api/discover/${serverId}`),
|
||||
enabled: false, // manual trigger
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutoImportTemplates() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (serverId: number) => apiClient.post<{ success: boolean; count: number }>(`/templates/api/auto-import/${serverId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: templateKeys.groups });
|
||||
qc.invalidateQueries({ queryKey: ['templates'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<OSTemplate>) => apiClient.post<OSTemplate>('/templates/api/templates', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: Partial<OSTemplate> & { id: number }) =>
|
||||
apiClient.put<OSTemplate>(`/templates/api/templates/${id}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/templates/api/templates/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
|
||||
});
|
||||
}
|
||||
140
frontend/src/hooks/use-users.ts
Normal file
140
frontend/src/hooks/use-users.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { User } from '@/types';
|
||||
|
||||
export const userKeys = {
|
||||
users: ['users'] as const,
|
||||
user: (id: number) => ['user', id] as const,
|
||||
roles: ['roles'] as const,
|
||||
permissions: ['permissions-v2'] as const,
|
||||
sessions: ['sessions'] as const,
|
||||
blockedIps: ['blocked-ips'] as const,
|
||||
securitySettings: ['admin-security-settings'] as const,
|
||||
securityEvents: ['security-events'] as const,
|
||||
};
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.users,
|
||||
queryFn: () => apiClient.get<User[]>('/admin/api/users'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUser(userId: number) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.user(userId),
|
||||
queryFn: () => apiClient.get<User>(`/admin/api/users/${userId}`),
|
||||
enabled: userId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; email: string; password: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
|
||||
apiClient.post<User>('/admin/api/users', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: number; email?: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
|
||||
apiClient.put<User>(`/admin/api/users/${id}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/admin/api/users/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (userId: number) =>
|
||||
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlockUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (userId: number) => apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.roles,
|
||||
queryFn: () => apiClient.get<Role[]>('/admin/api/roles'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRole() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; display_name: string; description?: string; permissions: string[] }) =>
|
||||
apiClient.post<Role>('/admin/api/roles', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRole() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: number; display_name?: string; description?: string; permissions?: string[]; is_active?: boolean }) =>
|
||||
apiClient.put<Role>(`/admin/api/roles/${id}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRole() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/admin/api/roles/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePermissions() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.permissions,
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>('/admin/api/permissions/v2'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSessions() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.sessions,
|
||||
queryFn: () => apiClient.get<any[]>('/admin/api/sessions'),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTerminateSession() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
|
||||
});
|
||||
}
|
||||
75
frontend/src/hooks/use-workspaces.ts
Normal file
75
frontend/src/hooks/use-workspaces.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { Workspace } from '@/types';
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: ['workspaces'] as const,
|
||||
detail: (id: number) => ['workspace', id] as const,
|
||||
};
|
||||
|
||||
interface WorkspaceDetail extends Omit<Workspace, 'servers' | 'users'> {
|
||||
servers: { id: number; name: string; ip_address: string; is_online: boolean; cluster_name?: string }[];
|
||||
users: { id: number; username: string; email: string; full_name?: string }[];
|
||||
}
|
||||
|
||||
export function useWorkspaces() {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.all,
|
||||
queryFn: () => apiClient.get<Workspace[]>('/api/workspaces'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkspace(id: number) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.detail(id),
|
||||
queryFn: () => apiClient.get<WorkspaceDetail>(`/api/workspaces/${id}`),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; description?: string; color?: string; server_ids?: number[]; user_ids?: number[] }) =>
|
||||
apiClient.post<WorkspaceDetail>('/api/workspaces', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: workspaceKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: number; name?: string; description?: string; color?: string }) =>
|
||||
apiClient.put<WorkspaceDetail>(`/api/workspaces/${id}`, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ['workspace'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/api/workspaces/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: workspaceKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceServers() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
|
||||
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/servers`, { ids }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceUsers() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
|
||||
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/users`, { ids }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
|
||||
});
|
||||
}
|
||||
140
frontend/src/index.css
Normal file
140
frontend/src/index.css
Normal file
@@ -0,0 +1,140 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "DM Sans", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
|
||||
--color-surface-0: #09090B;
|
||||
--color-surface-1: #111113;
|
||||
--color-surface-2: #1A1A1F;
|
||||
--color-text-primary: #F0F0F3;
|
||||
--color-text-secondary: #A0A0AB;
|
||||
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
|
||||
--sidebar-width: 232px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.4375rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.592 0.19 264);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.965 0.002 264);
|
||||
--secondary-foreground: oklch(0.205 0.015 264);
|
||||
--muted: oklch(0.965 0.002 264);
|
||||
--muted-foreground: oklch(0.556 0.015 264);
|
||||
--accent: oklch(0.965 0.002 264);
|
||||
--accent-foreground: oklch(0.205 0.015 264);
|
||||
--destructive: oklch(0.577 0.245 27.33);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.922 0.004 264);
|
||||
--input: oklch(0.922 0.004 264);
|
||||
--ring: oklch(0.592 0.19 264);
|
||||
--chart-1: oklch(0.592 0.19 264);
|
||||
--chart-2: oklch(0.723 0.191 149);
|
||||
--chart-3: oklch(0.768 0.165 75);
|
||||
--chart-4: oklch(0.577 0.245 27.33);
|
||||
--chart-5: oklch(0.586 0.24 293);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.592 0.19 264);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.965 0.002 264);
|
||||
--sidebar-accent-foreground: oklch(0.205 0.015 264);
|
||||
--sidebar-border: oklch(0.922 0.004 264);
|
||||
--sidebar-ring: oklch(0.592 0.19 264);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 286);
|
||||
--foreground: oklch(0.94 0.005 264);
|
||||
--card: oklch(0.18 0.006 286);
|
||||
--card-foreground: oklch(0.94 0.005 264);
|
||||
--popover: oklch(0.18 0.006 286);
|
||||
--popover-foreground: oklch(0.94 0.005 264);
|
||||
--primary: oklch(0.592 0.19 264);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.24 0.008 264);
|
||||
--secondary-foreground: oklch(0.94 0.005 264);
|
||||
--muted: oklch(0.24 0.008 264);
|
||||
--muted-foreground: oklch(0.64 0.015 264);
|
||||
--accent: oklch(0.24 0.008 264);
|
||||
--accent-foreground: oklch(0.94 0.005 264);
|
||||
--destructive: oklch(0.577 0.245 27.33);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.27 0.008 264);
|
||||
--input: oklch(0.27 0.008 264);
|
||||
--ring: oklch(0.592 0.19 264);
|
||||
--chart-1: oklch(0.592 0.19 264);
|
||||
--chart-2: oklch(0.723 0.191 149);
|
||||
--chart-3: oklch(0.768 0.165 75);
|
||||
--chart-4: oklch(0.577 0.245 27.33);
|
||||
--chart-5: oklch(0.586 0.24 293);
|
||||
--sidebar: oklch(0.17 0.006 286);
|
||||
--sidebar-foreground: oklch(0.94 0.005 264);
|
||||
--sidebar-primary: oklch(0.592 0.19 264);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.008 264);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.005 264);
|
||||
--sidebar-border: oklch(0.27 0.008 264);
|
||||
--sidebar-ring: oklch(0.592 0.19 264);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
}
|
||||
285
frontend/src/layouts/AppLayout.tsx
Normal file
285
frontend/src/layouts/AppLayout.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Link, useLocation, Outlet, useNavigate } from 'react-router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Monitor,
|
||||
Network,
|
||||
HardDrive,
|
||||
Archive,
|
||||
Globe,
|
||||
Settings,
|
||||
Users,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
FolderKanban,
|
||||
Sun,
|
||||
Moon,
|
||||
Menu,
|
||||
LogOut,
|
||||
Languages,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useWorkspaceStore } from '@/stores/workspace-store';
|
||||
import { useWorkspaces } from '@/hooks/use-workspaces';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: 'Infrastructure',
|
||||
items: [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' },
|
||||
{ label: 'Instances', icon: Monitor, path: '/instances' },
|
||||
{ label: 'Nodes', icon: Server, path: '/nodes' },
|
||||
{ label: 'Templates', icon: HardDrive, path: '/templates' },
|
||||
{ label: 'Backups', icon: Archive, path: '/backups' },
|
||||
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
items: [
|
||||
{ label: 'IPAM', icon: Globe, path: '/ipam' },
|
||||
{ label: 'Networks', icon: Network, path: '/networks' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Management',
|
||||
items: [
|
||||
{ label: 'Users', icon: Users, path: '/users' },
|
||||
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' },
|
||||
{ label: 'Logs', icon: FileText, path: '/logs' },
|
||||
{ label: 'Settings', icon: Settings, path: '/settings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SidebarContent() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<img src="/favicon.svg" alt="PVEmanager" className="h-8 w-8" />
|
||||
<span className="text-base font-semibold tracking-tight">PVEmanager</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-4">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`nav.${group.title.toLowerCase()}`, group.title)}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/dashboard' && location.pathname.startsWith(item.path));
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{t(`nav.${item.label.toLowerCase()}`, item.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* User section */}
|
||||
<UserSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSection() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<button className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent transition-colors" />}>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/20 text-xs font-semibold text-primary">
|
||||
{user?.username?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-medium">{user?.username ?? 'User'}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{user?.is_admin ? 'Admin' : 'User'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('nav.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('login.logout', 'Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { data: workspaces = [] } = useWorkspaces();
|
||||
const { activeWorkspaceId, setActiveWorkspace } = useWorkspaceStore();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const active = workspaces.find((w) => w.id === activeWorkspaceId);
|
||||
|
||||
const handleSwitch = (id: number | null) => {
|
||||
setActiveWorkspace(id);
|
||||
// Invalidate all queries to refetch with new workspace
|
||||
qc.invalidateQueries();
|
||||
};
|
||||
|
||||
if (workspaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" className="gap-1.5 max-w-[200px]" />}>
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate text-xs">{active?.name || t('workspaces.all', 'All workspaces')}</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
<DropdownMenuItem onClick={() => handleSwitch(null)}>
|
||||
<Building2 className="mr-2 h-3.5 w-3.5" />
|
||||
{t('workspaces.all', 'All workspaces')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{workspaces.map((w) => (
|
||||
<DropdownMenuItem key={w.id} onClick={() => handleSwitch(w.id)}>
|
||||
<FolderKanban className="mr-2 h-3.5 w-3.5" />
|
||||
<span className="truncate">{w.name}</span>
|
||||
{w.id === activeWorkspaceId && (
|
||||
<span className="ml-auto text-primary">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar() {
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next = i18n.language === 'ru' ? 'en' : 'ru';
|
||||
i18n.changeLanguage(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b px-4 lg:px-6">
|
||||
{/* Mobile menu */}
|
||||
<Sheet>
|
||||
<SheetTrigger render={<Button variant="ghost" size="icon" className="lg:hidden" />}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[var(--sidebar-width)] p-0">
|
||||
<SidebarContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<WorkspaceSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button variant="ghost" size="icon" onClick={toggleLanguage} />}>
|
||||
<Languages className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{i18n.language === 'ru' ? 'English' : 'Русский'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button variant="ghost" size="icon" onClick={toggleTheme} />}>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{theme === 'dark' ? 'Light mode' : 'Dark mode'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<NotificationsDropdown />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden w-[var(--sidebar-width)] shrink-0 border-r bg-card lg:block">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/layouts/AuthLayout.tsx
Normal file
20
frontend/src/layouts/AuthLayout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { Monitor } from 'lucide-react';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<Monitor className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">PVEmanager</h1>
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/layouts/ConsoleLayout.tsx
Normal file
9
frontend/src/layouts/ConsoleLayout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function ConsoleLayout() {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-black">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
frontend/src/lib/api-client.ts
Normal file
154
frontend/src/lib/api-client.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ApiError } from '@/types';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
function getActiveWorkspaceId(): number | null {
|
||||
const stored = localStorage.getItem('pve-active-workspace');
|
||||
return stored ? Number(stored) : null;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private token: string | null = null;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('access_token');
|
||||
if (this.token) {
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('access_token', token);
|
||||
this.scheduleRefresh();
|
||||
} else {
|
||||
localStorage.removeItem('access_token');
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private getTokenExpiry(): number | null {
|
||||
if (!this.token) return null;
|
||||
try {
|
||||
const payload = JSON.parse(atob(this.token.split('.')[1]));
|
||||
return payload.exp ? payload.exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh() {
|
||||
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||
const expiry = this.getTokenExpiry();
|
||||
if (!expiry) return;
|
||||
|
||||
const refreshIn = expiry - Date.now() - 2 * 60 * 1000; // 2 min before expiry
|
||||
if (refreshIn <= 0) {
|
||||
this.refreshToken();
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => this.refreshToken(), refreshIn);
|
||||
}
|
||||
|
||||
private async refreshToken() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.setToken(data.access_token);
|
||||
} else {
|
||||
this.setToken(null);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch {
|
||||
// Network error, try again later
|
||||
}
|
||||
}
|
||||
|
||||
async request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const wsId = getActiveWorkspaceId();
|
||||
if (wsId) {
|
||||
headers['X-Active-Workspace'] = String(wsId);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
this.setToken(null);
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const error: ApiError = await res.json().catch(() => ({
|
||||
detail: `HTTP ${res.status}: ${res.statusText}`,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
get<T>(path: string) {
|
||||
return this.request<T>(path);
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown) {
|
||||
return this.request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
put<T>(path: string, body?: unknown) {
|
||||
return this.request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown) {
|
||||
return this.request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
delete<T>(path: string) {
|
||||
return this.request<T>(path, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
29
frontend/src/lib/format.ts
Normal file
29
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '-';
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, decimals = 1): string {
|
||||
return `${value.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
export function formatCPU(cpuFraction: number, maxcpu: number): string {
|
||||
return `${(cpuFraction * 100).toFixed(1)}% of ${maxcpu}`;
|
||||
}
|
||||
|
||||
export function vmTypeLabel(type: string): string {
|
||||
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
||||
}
|
||||
26
frontend/src/lib/i18n.ts
Normal file
26
frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import en from '@/locales/en.json';
|
||||
import ru from '@/locales/ru.json';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ru: { translation: ru },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
lookupLocalStorage: 'pve-lang',
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
155
frontend/src/lib/websocket.ts
Normal file
155
frontend/src/lib/websocket.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
type MessageHandler = (data: unknown) => void;
|
||||
|
||||
interface WSOptions {
|
||||
url: string;
|
||||
onMessage?: MessageHandler;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onError?: (event: Event) => void;
|
||||
reconnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
export class WebSocketManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private options: WSOptions;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private subscribers = new Map<string, Set<MessageHandler>>();
|
||||
private _isConnected = false;
|
||||
|
||||
constructor(options: WSOptions) {
|
||||
this.options = {
|
||||
reconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this._isConnected;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const separator = this.options.url.includes('?') ? '&' : '?';
|
||||
const url = token
|
||||
? `${this.options.url}${separator}token=${encodeURIComponent(token)}`
|
||||
: this.options.url;
|
||||
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.startPing();
|
||||
this.options.onOpen?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.options.onMessage?.(data);
|
||||
|
||||
// Route to channel subscribers
|
||||
if (data.channel) {
|
||||
const handlers = this.subscribers.get(data.channel);
|
||||
handlers?.forEach((handler) => handler(data));
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON message
|
||||
this.options.onMessage?.(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._isConnected = false;
|
||||
this.stopPing();
|
||||
this.options.onClose?.();
|
||||
this.tryReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
this.options.onError?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.options.reconnect = false;
|
||||
this.stopPing();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this._isConnected = false;
|
||||
}
|
||||
|
||||
send(data: unknown) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(channel: string, handler: MessageHandler) {
|
||||
if (!this.subscribers.has(channel)) {
|
||||
this.subscribers.set(channel, new Set());
|
||||
}
|
||||
this.subscribers.get(channel)!.add(handler);
|
||||
|
||||
// Send subscribe message
|
||||
this.send({ type: 'subscribe', channel });
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.subscribers.get(channel)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
private tryReconnect() {
|
||||
if (
|
||||
!this.options.reconnect ||
|
||||
this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 10)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect();
|
||||
}, this.options.reconnectInterval);
|
||||
}
|
||||
|
||||
private startPing() {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.send({ type: 'ping' });
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private stopPing() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton for tasks WebSocket
|
||||
let tasksWs: WebSocketManager | null = null;
|
||||
|
||||
export function getTasksWebSocket(): WebSocketManager {
|
||||
if (!tasksWs) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
tasksWs = new WebSocketManager({
|
||||
url: `${protocol}//${window.location.host}/ws/tasks`,
|
||||
});
|
||||
}
|
||||
return tasksWs;
|
||||
}
|
||||
269
frontend/src/locales/en.json
Normal file
269
frontend/src/locales/en.json
Normal file
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"nav": {
|
||||
"infrastructure": "Infrastructure",
|
||||
"network": "Network",
|
||||
"management": "Management",
|
||||
"dashboard": "Dashboard",
|
||||
"instances": "Instances",
|
||||
"nodes": "Nodes",
|
||||
"templates": "Templates",
|
||||
"backups": "Backups",
|
||||
"tasks": "Tasks",
|
||||
"ipam": "IPAM",
|
||||
"networks": "Networks",
|
||||
"users": "Users",
|
||||
"workspaces": "Workspaces",
|
||||
"logs": "Logs",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in to your account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"username_placeholder": "Enter username",
|
||||
"password_placeholder": "Enter password",
|
||||
"sign_in": "Sign in",
|
||||
"signing_in": "Signing in...",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"nodes": "Nodes",
|
||||
"virtual_machines": "Virtual Machines",
|
||||
"containers": "Containers",
|
||||
"alerts": "Alerts",
|
||||
"memory": "Memory",
|
||||
"storage": "Storage",
|
||||
"instances": "Instances",
|
||||
"recent_activity": "Recent Activity"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"search": "Search...",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"node": "Node",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"confirm_delete": "Are you sure you want to delete this?",
|
||||
"no_data": "No data available",
|
||||
"all": "All",
|
||||
"date": "Date",
|
||||
"user": "User",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"refresh": "Refresh",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"console": "Console",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"snapshots": "Snapshots",
|
||||
"suspend": "Suspend"
|
||||
},
|
||||
"instances": {
|
||||
"create": "Create Instance",
|
||||
"search_placeholder": "Search by name, IP, or VMID...",
|
||||
"all_statuses": "All statuses",
|
||||
"all_nodes": "All nodes",
|
||||
"no_instances": "No instances found",
|
||||
"try_other_search": "Try a different search query",
|
||||
"create_first": "Create your first instance",
|
||||
"showing": "Showing",
|
||||
"of": "of",
|
||||
"overview": "Overview",
|
||||
"graphs": "Graphs",
|
||||
"networking": "Networking",
|
||||
"destroy": "Destroy"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Create Instance",
|
||||
"step_server": "Server",
|
||||
"step_template": "Template",
|
||||
"step_config": "Configuration",
|
||||
"step_confirm": "Confirm",
|
||||
"select_server": "Select server",
|
||||
"select_template": "Select template",
|
||||
"hostname": "Hostname",
|
||||
"cpu": "CPU Cores",
|
||||
"memory": "Memory (MB)",
|
||||
"disk": "Disk (GB)",
|
||||
"network": "Network",
|
||||
"password": "Password",
|
||||
"ssh_keys": "SSH Keys",
|
||||
"deploy": "Deploy",
|
||||
"deploying": "Deploying...",
|
||||
"os_group": "OS Group",
|
||||
"ipam_network": "IPAM Network",
|
||||
"auto_allocate_ip": "Auto-allocate IP"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Servers",
|
||||
"vms": "VMs",
|
||||
"containers": "Containers",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"uptime": "Uptime",
|
||||
"cluster_nodes": "Cluster Nodes",
|
||||
"vm_list": "Virtual Machines on Server"
|
||||
},
|
||||
"templates": {
|
||||
"title": "OS Templates",
|
||||
"search": "Search templates...",
|
||||
"all_groups": "All groups",
|
||||
"all_servers": "All servers",
|
||||
"auto_import": "Auto Import",
|
||||
"importing": "Importing...",
|
||||
"no_templates": "No templates found",
|
||||
"os": "Operating System",
|
||||
"version": "Version"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"files": "Backup Files",
|
||||
"jobs": "Scheduled Jobs",
|
||||
"storage": "Storage",
|
||||
"server": "Server",
|
||||
"size": "Size",
|
||||
"date": "Date",
|
||||
"restore": "Restore",
|
||||
"download": "Download",
|
||||
"schedule": "Schedule",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"run_now": "Run Now",
|
||||
"no_backups": "No backups found"
|
||||
},
|
||||
"ipam": {
|
||||
"dashboard": "IPAM Dashboard",
|
||||
"networks": "Networks",
|
||||
"allocations": "Allocations",
|
||||
"history": "History",
|
||||
"total_ips": "Total IPs",
|
||||
"allocated": "Allocated",
|
||||
"reserved": "Reserved",
|
||||
"available": "Available",
|
||||
"utilization": "Utilization",
|
||||
"network": "Network",
|
||||
"network_info": "Network Information",
|
||||
"pools": "Pools",
|
||||
"range": "Range",
|
||||
"usage": "Usage",
|
||||
"resource": "Resource",
|
||||
"description": "Description",
|
||||
"action": "Action",
|
||||
"details": "Details",
|
||||
"server": "Server",
|
||||
"bridge": "Bridge",
|
||||
"create_network": "Create Network",
|
||||
"cidr": "CIDR",
|
||||
"gateway": "Gateway",
|
||||
"vms_linked": "VMs Linked",
|
||||
"network_overview": "Network Overview"
|
||||
},
|
||||
"logs": {
|
||||
"level": "Level",
|
||||
"category": "Category",
|
||||
"message": "Message",
|
||||
"delete_old": "Delete old logs"
|
||||
},
|
||||
"settings": {
|
||||
"profile": "Profile",
|
||||
"panel": "Panel",
|
||||
"security": "Security",
|
||||
"notifications": "Notifications",
|
||||
"about": "About",
|
||||
"profile_info": "Profile Information",
|
||||
"username": "Username",
|
||||
"display_name": "Display Name",
|
||||
"change_password": "Change Password",
|
||||
"current_password": "Current Password",
|
||||
"new_password": "New Password",
|
||||
"confirm_password": "Confirm Password",
|
||||
"password_mismatch": "Passwords do not match",
|
||||
"panel_settings": "Panel Settings",
|
||||
"panel_name": "Panel Name",
|
||||
"language": "Language",
|
||||
"security_settings": "Security Settings",
|
||||
"max_attempts": "Max Login Attempts",
|
||||
"lockout_duration": "Lockout Duration",
|
||||
"session_timeout": "Session Timeout",
|
||||
"seconds": "seconds",
|
||||
"version": "Version",
|
||||
"check_updates": "Check for Updates",
|
||||
"update_available": "Update available",
|
||||
"up_to_date": "You are up to date"
|
||||
},
|
||||
"users": {
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"sessions": "Sessions",
|
||||
"create": "Create User",
|
||||
"create_role": "Create Role",
|
||||
"role": "Role",
|
||||
"last_login": "Last Login",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"locked": "Locked",
|
||||
"reset_password": "Reset Password",
|
||||
"unlock": "Unlock",
|
||||
"permissions_count": "Permissions",
|
||||
"active_sessions": "Active Sessions",
|
||||
"user_agent": "User Agent",
|
||||
"created": "Created",
|
||||
"expires": "Expires"
|
||||
},
|
||||
"workspaces": {
|
||||
"create": "Create Workspace",
|
||||
"edit": "Edit Workspace",
|
||||
"servers": "Servers",
|
||||
"users": "Users",
|
||||
"created": "Created",
|
||||
"all": "All workspaces",
|
||||
"select": "Select workspace"
|
||||
},
|
||||
"tasks": {
|
||||
"clear_completed": "Clear Completed",
|
||||
"running": "Running",
|
||||
"queued": "Queued",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"tasks_count": "tasks",
|
||||
"description": "Description",
|
||||
"server": "Server",
|
||||
"node": "Node",
|
||||
"started": "Started",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"mark_all_read": "Mark all read",
|
||||
"empty": "No notifications"
|
||||
},
|
||||
"graphs": {
|
||||
"cpu_usage": "CPU Usage",
|
||||
"memory_usage": "Memory Usage",
|
||||
"network_in": "Network In",
|
||||
"network_out": "Network Out",
|
||||
"disk_read": "Disk Read",
|
||||
"disk_write": "Disk Write"
|
||||
},
|
||||
"console": {
|
||||
"connecting": "Connecting to console...",
|
||||
"reconnect": "Reconnect",
|
||||
"connected": "Connected",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
269
frontend/src/locales/ru.json
Normal file
269
frontend/src/locales/ru.json
Normal file
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"nav": {
|
||||
"infrastructure": "Инфраструктура",
|
||||
"network": "Сеть",
|
||||
"management": "Управление",
|
||||
"dashboard": "Дашборд",
|
||||
"instances": "Инстансы",
|
||||
"nodes": "Узлы",
|
||||
"templates": "Шаблоны",
|
||||
"backups": "Бэкапы",
|
||||
"tasks": "Задачи",
|
||||
"ipam": "IPAM",
|
||||
"networks": "Сети",
|
||||
"users": "Пользователи",
|
||||
"workspaces": "Рабочие области",
|
||||
"logs": "Логи",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"login": {
|
||||
"title": "Вход в аккаунт",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"username_placeholder": "Введите имя пользователя",
|
||||
"password_placeholder": "Введите пароль",
|
||||
"sign_in": "Войти",
|
||||
"signing_in": "Входим...",
|
||||
"logout": "Выход"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Дашборд",
|
||||
"nodes": "Узлы",
|
||||
"virtual_machines": "Виртуальные машины",
|
||||
"containers": "Контейнеры",
|
||||
"alerts": "Уведомления",
|
||||
"memory": "Память",
|
||||
"storage": "Хранилище",
|
||||
"instances": "Инстансы",
|
||||
"recent_activity": "Последняя активность"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"create": "Создать",
|
||||
"search": "Поиск...",
|
||||
"actions": "Действия",
|
||||
"status": "Статус",
|
||||
"name": "Имя",
|
||||
"type": "Тип",
|
||||
"node": "Узел",
|
||||
"running": "Работает",
|
||||
"stopped": "Остановлен",
|
||||
"online": "Онлайн",
|
||||
"offline": "Офлайн",
|
||||
"confirm_delete": "Вы уверены, что хотите удалить?",
|
||||
"no_data": "Нет данных",
|
||||
"all": "Все",
|
||||
"date": "Дата",
|
||||
"user": "Пользователь",
|
||||
"prev": "Назад",
|
||||
"next": "Далее",
|
||||
"refresh": "Обновить",
|
||||
"back": "Назад",
|
||||
"close": "Закрыть",
|
||||
"confirm": "Подтвердить",
|
||||
"console": "Консоль",
|
||||
"start": "Запустить",
|
||||
"stop": "Остановить",
|
||||
"restart": "Перезагрузить",
|
||||
"snapshots": "Снимки",
|
||||
"suspend": "Приостановить"
|
||||
},
|
||||
"instances": {
|
||||
"create": "Создать инстанс",
|
||||
"search_placeholder": "Поиск по имени, IP или VMID...",
|
||||
"all_statuses": "Все статусы",
|
||||
"all_nodes": "Все узлы",
|
||||
"no_instances": "Инстансы не найдены",
|
||||
"try_other_search": "Попробуйте другой поисковый запрос",
|
||||
"create_first": "Создайте свой первый инстанс",
|
||||
"showing": "Показано",
|
||||
"of": "из",
|
||||
"overview": "Обзор",
|
||||
"graphs": "Графики",
|
||||
"networking": "Сеть",
|
||||
"destroy": "Уничтожить"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Создание инстанса",
|
||||
"step_server": "Сервер",
|
||||
"step_template": "Шаблон",
|
||||
"step_config": "Настройка",
|
||||
"step_confirm": "Подтверждение",
|
||||
"select_server": "Выберите сервер",
|
||||
"select_template": "Выберите шаблон",
|
||||
"hostname": "Имя хоста",
|
||||
"cpu": "Ядра CPU",
|
||||
"memory": "Память (МБ)",
|
||||
"disk": "Диск (ГБ)",
|
||||
"network": "Сеть",
|
||||
"password": "Пароль",
|
||||
"ssh_keys": "SSH ключи",
|
||||
"deploy": "Развернуть",
|
||||
"deploying": "Развертывание...",
|
||||
"os_group": "Группа ОС",
|
||||
"ipam_network": "Сеть IPAM",
|
||||
"auto_allocate_ip": "Авто-выделение IP"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Серверы",
|
||||
"vms": "ВМ",
|
||||
"containers": "Контейнеры",
|
||||
"cpu": "CPU",
|
||||
"memory": "Память",
|
||||
"disk": "Диск",
|
||||
"uptime": "Аптайм",
|
||||
"cluster_nodes": "Узлы кластера",
|
||||
"vm_list": "Виртуальные машины на сервере"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны ОС",
|
||||
"search": "Поиск шаблонов...",
|
||||
"all_groups": "Все группы",
|
||||
"all_servers": "Все серверы",
|
||||
"auto_import": "Авто-импорт",
|
||||
"importing": "Импорт...",
|
||||
"no_templates": "Шаблоны не найдены",
|
||||
"os": "Операционная система",
|
||||
"version": "Версия"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Бэкапы",
|
||||
"files": "Файлы бэкапов",
|
||||
"jobs": "Задания по расписанию",
|
||||
"storage": "Хранилище",
|
||||
"server": "Сервер",
|
||||
"size": "Размер",
|
||||
"date": "Дата",
|
||||
"restore": "Восстановить",
|
||||
"download": "Скачать",
|
||||
"schedule": "Расписание",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"run_now": "Запустить сейчас",
|
||||
"no_backups": "Бэкапы не найдены"
|
||||
},
|
||||
"ipam": {
|
||||
"dashboard": "IPAM Дашборд",
|
||||
"networks": "Сети",
|
||||
"allocations": "Распределения",
|
||||
"history": "История",
|
||||
"total_ips": "Всего IP",
|
||||
"allocated": "Выделено",
|
||||
"reserved": "Зарезервировано",
|
||||
"available": "Доступно",
|
||||
"utilization": "Использование",
|
||||
"network": "Сеть",
|
||||
"network_info": "Информация о сети",
|
||||
"pools": "Пулы",
|
||||
"range": "Диапазон",
|
||||
"usage": "Использование",
|
||||
"resource": "Ресурс",
|
||||
"description": "Описание",
|
||||
"action": "Действие",
|
||||
"details": "Детали",
|
||||
"server": "Сервер",
|
||||
"bridge": "Мост",
|
||||
"create_network": "Создать сеть",
|
||||
"cidr": "CIDR",
|
||||
"gateway": "Шлюз",
|
||||
"vms_linked": "Привязано ВМ",
|
||||
"network_overview": "Обзор сетей"
|
||||
},
|
||||
"logs": {
|
||||
"level": "Уровень",
|
||||
"category": "Категория",
|
||||
"message": "Сообщение",
|
||||
"delete_old": "Удалить старые"
|
||||
},
|
||||
"settings": {
|
||||
"profile": "Профиль",
|
||||
"panel": "Панель",
|
||||
"security": "Безопасность",
|
||||
"notifications": "Уведомления",
|
||||
"about": "О системе",
|
||||
"profile_info": "Информация профиля",
|
||||
"username": "Имя пользователя",
|
||||
"display_name": "Отображаемое имя",
|
||||
"change_password": "Сменить пароль",
|
||||
"current_password": "Текущий пароль",
|
||||
"new_password": "Новый пароль",
|
||||
"confirm_password": "Подтвердите пароль",
|
||||
"password_mismatch": "Пароли не совпадают",
|
||||
"panel_settings": "Настройки панели",
|
||||
"panel_name": "Название панели",
|
||||
"language": "Язык",
|
||||
"security_settings": "Настройки безопасности",
|
||||
"max_attempts": "Макс. попыток входа",
|
||||
"lockout_duration": "Время блокировки",
|
||||
"session_timeout": "Таймаут сессии",
|
||||
"seconds": "секунд",
|
||||
"version": "Версия",
|
||||
"check_updates": "Проверить обновления",
|
||||
"update_available": "Доступно обновление",
|
||||
"up_to_date": "Установлена последняя версия"
|
||||
},
|
||||
"users": {
|
||||
"users": "Пользователи",
|
||||
"roles": "Роли",
|
||||
"sessions": "Сессии",
|
||||
"create": "Создать пользователя",
|
||||
"create_role": "Создать роль",
|
||||
"role": "Роль",
|
||||
"last_login": "Последний вход",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"locked": "Заблокирован",
|
||||
"reset_password": "Сбросить пароль",
|
||||
"unlock": "Разблокировать",
|
||||
"permissions_count": "Разрешения",
|
||||
"active_sessions": "Активные сессии",
|
||||
"user_agent": "User Agent",
|
||||
"created": "Создано",
|
||||
"expires": "Истекает"
|
||||
},
|
||||
"workspaces": {
|
||||
"create": "Создать рабочую область",
|
||||
"edit": "Редактировать рабочую область",
|
||||
"servers": "Серверы",
|
||||
"users": "Пользователи",
|
||||
"created": "Создано",
|
||||
"all": "Все рабочие области",
|
||||
"select": "Выбрать рабочую область"
|
||||
},
|
||||
"tasks": {
|
||||
"clear_completed": "Очистить завершённые",
|
||||
"running": "Выполняется",
|
||||
"queued": "В очереди",
|
||||
"completed": "Завершено",
|
||||
"failed": "Ошибка",
|
||||
"tasks_count": "задач",
|
||||
"description": "Описание",
|
||||
"server": "Сервер",
|
||||
"node": "Узел",
|
||||
"started": "Запущено",
|
||||
"progress": "Прогресс"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Уведомления",
|
||||
"mark_all_read": "Прочитать все",
|
||||
"empty": "Нет уведомлений"
|
||||
},
|
||||
"graphs": {
|
||||
"cpu_usage": "Загрузка CPU",
|
||||
"memory_usage": "Использование памяти",
|
||||
"network_in": "Входящий трафик",
|
||||
"network_out": "Исходящий трафик",
|
||||
"disk_read": "Чтение диска",
|
||||
"disk_write": "Запись диска"
|
||||
},
|
||||
"console": {
|
||||
"connecting": "Подключение к консоли...",
|
||||
"reconnect": "Переподключить",
|
||||
"connected": "Подключено",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
}
|
||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './lib/i18n'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
87
frontend/src/router.tsx
Normal file
87
frontend/src/router.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import AppLayout from '@/layouts/AppLayout';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import ConsoleLayout from '@/layouts/ConsoleLayout';
|
||||
import { ProtectedRoute, PublicRoute } from '@/components/shared/route-guards';
|
||||
import LoginPage from '@/features/auth/LoginPage';
|
||||
import DashboardPage from '@/features/dashboard/DashboardPage';
|
||||
import InstancesPage from '@/features/instances/InstancesPage';
|
||||
import InstanceDetailPage from '@/features/instances/InstanceDetailPage';
|
||||
import ConsolePage from '@/features/console/ConsolePage';
|
||||
|
||||
// Lazy-loaded pages
|
||||
const CreateInstanceWizard = lazy(() => import('@/features/instances/CreateInstanceWizard'));
|
||||
const NodesPage = lazy(() => import('@/features/nodes/NodesPage'));
|
||||
const NodeDetailPage = lazy(() => import('@/features/nodes/NodeDetailPage'));
|
||||
const TemplatesPage = lazy(() => import('@/features/templates/TemplatesPage'));
|
||||
const BackupsPage = lazy(() => import('@/features/backups/BackupsPage'));
|
||||
const TasksPage = lazy(() => import('@/features/tasks/TasksPage'));
|
||||
const IPAMDashboardPage = lazy(() => import('@/features/ipam/IPAMDashboardPage'));
|
||||
const IPAMNetworksPage = lazy(() => import('@/features/ipam/IPAMNetworksPage'));
|
||||
const IPAMNetworkDetailPage = lazy(() => import('@/features/ipam/IPAMNetworkDetailPage'));
|
||||
const IPAMAllocationsPage = lazy(() => import('@/features/ipam/IPAMAllocationsPage'));
|
||||
const IPAMHistoryPage = lazy(() => import('@/features/ipam/IPAMHistoryPage'));
|
||||
const UsersPage = lazy(() => import('@/features/users/UsersPage'));
|
||||
const WorkspacesPage = lazy(() => import('@/features/workspaces/WorkspacesPage'));
|
||||
const LogsPage = lazy(() => import('@/features/logs/LogsPage'));
|
||||
const SettingsPage = lazy(() => import('@/features/settings/SettingsPage'));
|
||||
|
||||
function LazyFallback() {
|
||||
return <div className="flex items-center justify-center py-20 text-muted-foreground text-sm">Loading…</div>;
|
||||
}
|
||||
|
||||
function SuspenseWrap({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<LazyFallback />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <PublicRoute />,
|
||||
children: [
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
children: [{ index: true, element: <LoginPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
// Console — separate layout (no sidebar)
|
||||
{
|
||||
path: '/console/:serverId/:vmid',
|
||||
element: <ConsoleLayout />,
|
||||
children: [{ index: true, element: <ConsolePage /> }],
|
||||
},
|
||||
// Main app layout
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/instances', element: <InstancesPage /> },
|
||||
{ path: '/instances/create', element: <SuspenseWrap><CreateInstanceWizard /></SuspenseWrap> },
|
||||
{ path: '/instances/:serverId/:vmid', element: <InstanceDetailPage /> },
|
||||
{ path: '/nodes', element: <SuspenseWrap><NodesPage /></SuspenseWrap> },
|
||||
{ path: '/nodes/:serverId', element: <SuspenseWrap><NodeDetailPage /></SuspenseWrap> },
|
||||
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
|
||||
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
|
||||
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
|
||||
{ path: '/ipam', element: <SuspenseWrap><IPAMDashboardPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/network/:id', element: <SuspenseWrap><IPAMNetworkDetailPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/allocations', element: <SuspenseWrap><IPAMAllocationsPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/history', element: <SuspenseWrap><IPAMHistoryPage /></SuspenseWrap> },
|
||||
{ path: '/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
|
||||
{ path: '/users', element: <SuspenseWrap><UsersPage /></SuspenseWrap> },
|
||||
{ path: '/workspaces', element: <SuspenseWrap><WorkspacesPage /></SuspenseWrap> },
|
||||
{ path: '/logs', element: <SuspenseWrap><LogsPage /></SuspenseWrap> },
|
||||
{ path: '/settings', element: <SuspenseWrap><SettingsPage /></SuspenseWrap> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/dashboard" replace /> },
|
||||
]);
|
||||
70
frontend/src/stores/auth-store.ts
Normal file
70
frontend/src/stores/auth-store.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { User, LoginRequest, AuthResponse } from '@/types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadUser: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('access_token'),
|
||||
isAuthenticated: !!localStorage.getItem('access_token'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (credentials: LoginRequest) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const data = await apiClient.post<AuthResponse>('/api/auth/login', credentials);
|
||||
apiClient.setToken(data.access_token);
|
||||
set({ token: data.access_token, isAuthenticated: true, isLoading: false });
|
||||
|
||||
// Load user info
|
||||
const user = await apiClient.get<User>('/api/auth/me');
|
||||
set({ user });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Login failed';
|
||||
set({ error: message, isLoading: false, isAuthenticated: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiClient.post('/api/auth/logout');
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
apiClient.setToken(null);
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
loadUser: async () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
set({ isAuthenticated: false, isLoading: false });
|
||||
return;
|
||||
}
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
apiClient.setToken(token);
|
||||
const user = await apiClient.get<User>('/api/auth/me');
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
} catch {
|
||||
apiClient.setToken(null);
|
||||
set({ user: null, token: null, isAuthenticated: false, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
45
frontend/src/stores/theme-store.ts
Normal file
45
frontend/src/stores/theme-store.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
const stored = localStorage.getItem('pve-theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'dark'; // Dark by default per TZ
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('pve-theme', theme);
|
||||
}
|
||||
|
||||
// Apply initial theme immediately
|
||||
applyTheme(getInitialTheme());
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set) => ({
|
||||
theme: getInitialTheme(),
|
||||
|
||||
setTheme: (theme: Theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
set((state) => {
|
||||
const next = state.theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
return { theme: next };
|
||||
});
|
||||
},
|
||||
}));
|
||||
21
frontend/src/stores/workspace-store.ts
Normal file
21
frontend/src/stores/workspace-store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface WorkspaceState {
|
||||
activeWorkspaceId: number | null;
|
||||
setActiveWorkspace: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
|
||||
activeWorkspaceId: (() => {
|
||||
const stored = localStorage.getItem('pve-active-workspace');
|
||||
return stored ? Number(stored) : null;
|
||||
})(),
|
||||
setActiveWorkspace: (id) => {
|
||||
if (id !== null) {
|
||||
localStorage.setItem('pve-active-workspace', String(id));
|
||||
} else {
|
||||
localStorage.removeItem('pve-active-workspace');
|
||||
}
|
||||
set({ activeWorkspaceId: id });
|
||||
},
|
||||
}));
|
||||
363
frontend/src/types/index.ts
Normal file
363
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
// ==================== User Types ====================
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
role_id?: number;
|
||||
role_name?: string;
|
||||
is_locked?: boolean;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
// ==================== Proxmox Server Types ====================
|
||||
|
||||
export interface ProxmoxServer {
|
||||
id: number;
|
||||
name: string;
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
port: number;
|
||||
api_user: string;
|
||||
verify_ssl: boolean;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_check?: string;
|
||||
is_online?: boolean;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface ProxmoxServerCreate {
|
||||
name: string;
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
port?: number;
|
||||
api_user?: string;
|
||||
api_token_name?: string;
|
||||
api_token_value?: string;
|
||||
use_password?: boolean;
|
||||
password?: string;
|
||||
verify_ssl?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ==================== VM / Container Types ====================
|
||||
|
||||
export type VMType = 'qemu' | 'lxc';
|
||||
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown';
|
||||
|
||||
export interface VMInstance {
|
||||
vmid: number;
|
||||
name?: string;
|
||||
status: VMStatus;
|
||||
type: VMType;
|
||||
node: string;
|
||||
server_id?: number;
|
||||
server_name?: string;
|
||||
cpu?: number;
|
||||
mem?: number;
|
||||
maxmem?: number;
|
||||
disk?: number;
|
||||
maxdisk?: number;
|
||||
uptime?: number;
|
||||
netin?: number;
|
||||
netout?: number;
|
||||
ip_address?: string;
|
||||
tags?: string;
|
||||
template?: boolean;
|
||||
}
|
||||
|
||||
export interface VMConfig {
|
||||
cores?: number;
|
||||
memory?: number;
|
||||
disk?: string;
|
||||
name?: string;
|
||||
ostype?: string;
|
||||
net0?: string;
|
||||
boot?: string;
|
||||
agent?: number;
|
||||
onboot?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VMDeployRequest {
|
||||
template_id: number;
|
||||
name: string;
|
||||
vmid?: number;
|
||||
target_node?: string;
|
||||
cores?: number;
|
||||
memory?: number;
|
||||
disk?: number;
|
||||
target_storage?: string;
|
||||
start_after_create?: boolean;
|
||||
onboot?: boolean;
|
||||
enable_ha?: boolean;
|
||||
network_bridge?: string;
|
||||
ip_address?: string;
|
||||
gateway?: string;
|
||||
ipam_network_id?: number;
|
||||
ipam_pool_id?: number;
|
||||
cloud_init_user?: string;
|
||||
cloud_init_password?: string;
|
||||
ssh_keys?: string;
|
||||
}
|
||||
|
||||
// ==================== Node Types ====================
|
||||
|
||||
export interface ProxmoxNode {
|
||||
node: string;
|
||||
status: 'online' | 'offline';
|
||||
cpu?: number;
|
||||
maxcpu?: number;
|
||||
mem?: number;
|
||||
maxmem?: number;
|
||||
disk?: number;
|
||||
maxdisk?: number;
|
||||
uptime?: number;
|
||||
server_id: number;
|
||||
server_name?: string;
|
||||
vm_count?: number;
|
||||
ct_count?: number;
|
||||
}
|
||||
|
||||
// ==================== IPAM Types ====================
|
||||
|
||||
export interface IPAMNetwork {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
network: string;
|
||||
gateway?: string;
|
||||
vlan_id?: number;
|
||||
dns_primary?: string;
|
||||
dns_secondary?: string;
|
||||
dns_domain?: string;
|
||||
proxmox_server_id?: number;
|
||||
proxmox_node?: string;
|
||||
proxmox_bridge?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_ips?: number;
|
||||
used_ips?: number;
|
||||
available_ips?: number;
|
||||
utilization_percent?: number;
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
export interface IPAMPool {
|
||||
id: number;
|
||||
network_id: number;
|
||||
name: string;
|
||||
pool_type: 'static' | 'dhcp' | 'reserved';
|
||||
range_start: string;
|
||||
range_end: string;
|
||||
auto_assign: boolean;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_ips?: number;
|
||||
used_ips?: number;
|
||||
available_ips?: number;
|
||||
}
|
||||
|
||||
export interface IPAMAllocation {
|
||||
id: number;
|
||||
network_id: number;
|
||||
pool_id?: number;
|
||||
ip_address: string;
|
||||
mac_address?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: number;
|
||||
resource_name?: string;
|
||||
network_name?: string;
|
||||
proxmox_server_id?: number;
|
||||
proxmox_vmid?: number;
|
||||
proxmox_node?: string;
|
||||
status: 'allocated' | 'reserved' | 'available' | 'conflict';
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ==================== Template Types ====================
|
||||
|
||||
export interface OSTemplateGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OSTemplate {
|
||||
id: number;
|
||||
group_id: number;
|
||||
server_id: number;
|
||||
name: string;
|
||||
vmid: number;
|
||||
node: string;
|
||||
default_cores: number;
|
||||
default_memory: number;
|
||||
default_disk: number;
|
||||
min_cores: number;
|
||||
min_memory: number;
|
||||
min_disk: number;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
group_name?: string;
|
||||
group_icon?: string;
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
// ==================== Workspace Types ====================
|
||||
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
servers?: WorkspaceServer[];
|
||||
users?: WorkspaceUser[];
|
||||
}
|
||||
|
||||
export interface WorkspaceServer {
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUser {
|
||||
user_id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// ==================== Notification Types ====================
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'error' | 'success';
|
||||
is_read: boolean;
|
||||
category?: string;
|
||||
resource_type?: string;
|
||||
resource_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ==================== Log Types ====================
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
category: string;
|
||||
message: string;
|
||||
user_id?: number;
|
||||
username?: string;
|
||||
ip_address?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ==================== Task Types ====================
|
||||
|
||||
export interface ProxmoxTask {
|
||||
upid: string;
|
||||
node: string;
|
||||
type: string;
|
||||
status?: string;
|
||||
exitstatus?: string;
|
||||
starttime: number;
|
||||
endtime?: number;
|
||||
user: string;
|
||||
server_id?: number;
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
// ==================== Backup Types ====================
|
||||
|
||||
export interface BackupJob {
|
||||
id: number;
|
||||
server_id: number;
|
||||
vmid?: number;
|
||||
schedule?: string;
|
||||
storage: string;
|
||||
mode?: string;
|
||||
compress?: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ==================== Snapshot Types ====================
|
||||
|
||||
export interface Snapshot {
|
||||
name: string;
|
||||
description?: string;
|
||||
snaptime?: number;
|
||||
parent?: string;
|
||||
vmstate?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Dashboard Types ====================
|
||||
|
||||
export interface DashboardStats {
|
||||
nodes_total: number;
|
||||
nodes_online: number;
|
||||
vms_total: number;
|
||||
vms_running: number;
|
||||
containers_total: number;
|
||||
containers_running: number;
|
||||
alerts_count: number;
|
||||
}
|
||||
|
||||
export interface ResourceMetrics {
|
||||
cpu_percent: number;
|
||||
memory_used: number;
|
||||
memory_total: number;
|
||||
memory_percent: number;
|
||||
storage_used: number;
|
||||
storage_total: number;
|
||||
storage_percent: number;
|
||||
}
|
||||
|
||||
// ==================== Common Types ====================
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
status_code?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user