chore: initialize repository with deployment baseline

This commit is contained in:
Austin A
2026-04-17 23:03:00 +01:00
parent f02ddf42aa
commit 5def26e0df
166 changed files with 43065 additions and 0 deletions

83
src/App.jsx Normal file
View File

@@ -0,0 +1,83 @@
import { Toaster } from "@/components/ui/toaster"
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientInstance } from '@/lib/query-client'
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom';
import PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
import AppLayout from './components/layout/AppLayout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import VirtualMachines from './pages/VirtualMachines';
import Nodes from './pages/Nodes';
import Tenants from './pages/Tenants';
import Billing from './pages/Billing';
import Backups from './pages/Backups';
import Monitoring from './pages/Monitoring';
import AuditLogs from './pages/AuditLogs';
import RBAC from './pages/RBAC';
import Settings from './pages/Settings';
import Operations from './pages/Operations';
import Provisioning from './pages/Provisioning';
import NetworkIpam from './pages/NetworkIpam';
import ClientArea from './pages/ClientArea';
import Security from './pages/Security';
const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated } = useAuth();
// Show loading spinner while checking app public settings or auth
if (isLoadingPublicSettings || isLoadingAuth) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="h-9 w-9 rounded-full border-4 border-muted border-t-primary animate-spin" />
</div>
);
}
// Handle authentication errors
if (authError?.type === 'user_not_registered') {
return <UserNotRegisteredError />;
}
return (
<Routes>
<Route path="/login" element={isAuthenticated ? <Navigate to="/" replace /> : <Login />} />
<Route element={isAuthenticated ? <AppLayout /> : <Navigate to="/login" replace />}>
<Route path="/" element={<Dashboard />} />
<Route path="/vms" element={<VirtualMachines />} />
<Route path="/nodes" element={<Nodes />} />
<Route path="/tenants" element={<Tenants />} />
<Route path="/billing" element={<Billing />} />
<Route path="/backups" element={<Backups />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/provisioning" element={<Provisioning />} />
<Route path="/network" element={<NetworkIpam />} />
<Route path="/security" element={<Security />} />
<Route path="/client" element={<ClientArea />} />
<Route path="/operations" element={<Operations />} />
<Route path="/audit-logs" element={<AuditLogs />} />
<Route path="/rbac" element={<RBAC />} />
<Route path="/settings" element={<Settings />} />
</Route>
<Route path="*" element={isAuthenticated ? <PageNotFound /> : <Navigate to="/login" replace />} />
</Routes>
);
};
function App() {
return (
<AuthProvider>
<QueryClientProvider client={queryClientInstance}>
<Router>
<AuthenticatedApp />
</Router>
<Toaster />
</QueryClientProvider>
</AuthProvider>
)
}
export default App

1129
src/api/appClient.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom";
import { useAuth } from "@/lib/AuthContext";
import UserNotRegisteredError from "@/components/UserNotRegisteredError";
const DefaultFallback = () => (
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="h-9 w-9 rounded-full border-4 border-muted border-t-primary animate-spin" />
</div>
);
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement = null }) {
const { isAuthenticated, isLoadingAuth, authError, checkAppState } = useAuth();
useEffect(() => {
if (!isAuthenticated && !isLoadingAuth) {
checkAppState();
}
}, [isAuthenticated, isLoadingAuth, checkAppState]);
if (isLoadingAuth) {
return fallback;
}
if (authError?.type === "user_not_registered") {
return <UserNotRegisteredError />;
}
if (!isAuthenticated) {
return unauthenticatedElement;
}
return <Outlet />;
}

View File

@@ -0,0 +1,34 @@
import React from "react";
export default function UserNotRegisteredError() {
return (
<div className="app-shell-bg flex min-h-screen items-center justify-center px-4 py-8">
<div className="w-full max-w-lg rounded-2xl border border-border bg-card p-8 shadow-[0_16px_45px_rgba(15,23,42,0.12)]">
<div className="text-center">
<div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-200">
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className="mb-3 text-3xl font-semibold tracking-tight text-foreground">Access Restricted</h1>
<p className="mb-7 text-sm text-muted-foreground">
Your account is authenticated but does not have application access. Contact an administrator to grant the correct role.
</p>
<div className="rounded-xl border border-border bg-muted/50 p-4 text-left text-sm text-muted-foreground">
<p className="font-medium text-foreground">Quick checks:</p>
<ul className="mt-2 list-inside list-disc space-y-1">
<li>Confirm you signed in with the expected organization account.</li>
<li>Request tenant membership or RBAC role assignment.</li>
<li>Retry login after the admin updates permissions.</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Bell, ChevronRight, Search } from "lucide-react";
import { Outlet, useLocation } from "react-router-dom";
import { Input } from "@/components/ui/input";
import Sidebar from "./Sidebar";
import { resolveNavigation } from "./nav-config";
export default function AppLayout() {
const location = useLocation();
const currentNav = resolveNavigation(location.pathname);
return (
<div className="min-h-screen bg-background text-foreground app-shell-bg flex">
<Sidebar />
<div className="relative flex-1 min-w-0">
<header className="sticky top-0 z-30 border-b border-border/80 bg-background/85 backdrop-blur-xl">
<div className="mx-auto flex h-16 w-full max-w-[1680px] items-center gap-3 px-4 md:px-6">
<div className="hidden lg:flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-medium">Control Plane</span>
<ChevronRight className="h-3.5 w-3.5" />
<span>{currentNav?.group ?? "Workspace"}</span>
<ChevronRight className="h-3.5 w-3.5" />
<span className="font-semibold text-foreground">{currentNav?.label ?? "Overview"}</span>
</div>
<div className="relative ml-auto w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Global search"
placeholder="Search resources, tenants, events..."
className="h-9 rounded-lg border-border bg-card/70 pl-9 pr-16 text-sm"
/>
<span className="pointer-events-none absolute right-2 top-1/2 hidden -translate-y-1/2 rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground md:block">
Ctrl K
</span>
</div>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card/70 text-muted-foreground transition-colors hover:text-foreground"
>
<Bell className="h-4 w-4" />
</button>
</div>
</header>
<main className="mx-auto w-full max-w-[1680px] px-4 pb-8 pt-5 md:px-6 md:pb-10 md:pt-6">
<div className="page-enter">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import {
Activity,
Boxes,
ChevronLeft,
ChevronRight,
CreditCard,
Database,
FileText,
HardDrive,
LayoutDashboard,
ListChecks,
LogOut,
Menu,
Network,
Server,
Settings,
Shield,
Users
} from "lucide-react";
import { cn } from "@/lib/utils";
import { appClient } from "@/api/appClient";
import { navigationGroups } from "./nav-config";
const iconMap = {
dashboard: LayoutDashboard,
monitoring: Activity,
operations: ListChecks,
audit: FileText,
vms: Server,
nodes: HardDrive,
provisioning: Boxes,
backups: Database,
network: Network,
security: Shield,
tenants: Users,
client: Users,
billing: CreditCard,
rbac: Shield,
settings: Settings
};
export default function Sidebar() {
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const isActive = (path) => {
if (path === "/") return location.pathname === "/";
return location.pathname.startsWith(path);
};
const sidebarContent = (
<aside
className={cn(
"flex h-full flex-col border-r border-sidebar-border bg-sidebar/95 backdrop-blur-xl transition-[width] duration-300",
collapsed ? "w-[88px]" : "w-[276px]"
)}
>
<div className="border-b border-sidebar-border px-4 py-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary ring-1 ring-primary/20">
<Server className="h-5 w-5" />
</div>
{!collapsed && (
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground tracking-tight">ProxPanel Cloud</p>
<p className="truncate text-[11px] text-muted-foreground">Enterprise Control Console</p>
</div>
)}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-3">
<div className="space-y-5">
{navigationGroups.map((group) => (
<div key={group.id} className="space-y-1.5">
{!collapsed && (
<p className="px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/90">
{group.label}
</p>
)}
{group.items.map((item) => {
const Icon = iconMap[item.iconKey] ?? LayoutDashboard;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
title={collapsed ? item.label : undefined}
onClick={() => setMobileOpen(false)}
className={cn(
"group relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all",
active
? "bg-primary/12 text-primary ring-1 ring-primary/20"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-foreground"
)}
>
{active && <span className="absolute left-0 top-2.5 bottom-2.5 w-[3px] rounded-r-full bg-primary" />}
<Icon className={cn("h-[17px] w-[17px] shrink-0", active ? "text-primary" : "text-muted-foreground group-hover:text-foreground")} />
{!collapsed && <span className="truncate font-medium">{item.label}</span>}
</Link>
);
})}
</div>
))}
</div>
</nav>
<div className="border-t border-sidebar-border p-2">
{!collapsed && (
<div className="mb-2 rounded-xl border border-border/80 bg-card/65 px-3 py-2">
<p className="text-[11px] font-semibold text-foreground">Production Workspace</p>
<p className="text-[11px] text-muted-foreground">Latency target: 99.95% SLA</p>
</div>
)}
<button
type="button"
onClick={() => appClient.auth.logout("/login")}
className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-rose-50 hover:text-rose-700"
>
<LogOut className="h-[17px] w-[17px] shrink-0" />
{!collapsed && <span className="font-medium">Sign Out</span>}
</button>
</div>
<div className="hidden border-t border-sidebar-border p-2 md:block">
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
className="inline-flex h-9 w-full items-center justify-center rounded-xl border border-border/80 bg-card/60 text-muted-foreground transition-colors hover:text-foreground"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
</aside>
);
return (
<>
<button
type="button"
onClick={() => setMobileOpen(true)}
className="fixed left-4 top-3 z-40 inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background/90 text-foreground shadow-sm backdrop-blur md:hidden"
>
<Menu className="h-5 w-5" />
</button>
{mobileOpen && (
<div className="fixed inset-0 z-40 bg-slate-950/45 backdrop-blur-[2px] md:hidden" onClick={() => setMobileOpen(false)}>
<div className="h-full max-w-[290px]" onClick={(event) => event.stopPropagation()}>
{sidebarContent}
</div>
</div>
)}
<div className="sticky top-0 hidden h-screen md:flex">{sidebarContent}</div>
</>
);
}

View File

@@ -0,0 +1,54 @@
export const navigationGroups = [
{
id: "overview",
label: "Overview",
items: [
{ path: "/", label: "Dashboard", iconKey: "dashboard" },
{ path: "/monitoring", label: "Monitoring", iconKey: "monitoring" },
{ path: "/operations", label: "Operations", iconKey: "operations" },
{ path: "/audit-logs", label: "Audit Logs", iconKey: "audit" }
]
},
{
id: "compute",
label: "Compute",
items: [
{ path: "/vms", label: "Virtual Machines", iconKey: "vms" },
{ path: "/nodes", label: "Nodes", iconKey: "nodes" },
{ path: "/provisioning", label: "Provisioning", iconKey: "provisioning" },
{ path: "/backups", label: "Backups", iconKey: "backups" }
]
},
{
id: "network",
label: "Network",
items: [
{ path: "/network", label: "IPAM & Pools", iconKey: "network" },
{ path: "/security", label: "Security", iconKey: "security" }
]
},
{
id: "tenant",
label: "Tenants",
items: [
{ path: "/tenants", label: "Tenants", iconKey: "tenants" },
{ path: "/client", label: "Client Area", iconKey: "client" },
{ path: "/billing", label: "Billing", iconKey: "billing" },
{ path: "/rbac", label: "RBAC", iconKey: "rbac" },
{ path: "/settings", label: "Settings", iconKey: "settings" }
]
}
];
export const flatNavigation = navigationGroups.flatMap((group) =>
group.items.map((item) => ({ ...item, group: group.label }))
);
export function resolveNavigation(pathname) {
if (!pathname || pathname === "/") {
return flatNavigation.find((item) => item.path === "/") ?? null;
}
const sortedBySpecificity = [...flatNavigation].sort((a, b) => b.path.length - a.path.length);
return sortedBySpecificity.find((item) => item.path !== "/" && pathname.startsWith(item.path)) ?? null;
}

View File

@@ -0,0 +1,14 @@
export default function EmptyState({ icon: Icon, title, description, action }) {
return (
<div className="surface-card p-10 text-center">
{Icon ? (
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icon className="h-6 w-6" />
</div>
) : null}
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description ? <p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">{description}</p> : null}
{action ? <div className="mt-5 flex justify-center">{action}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,12 @@
export default function PageHeader({ title, description, children }) {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">Enterprise Console</p>
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[30px]">{title}</h1>
{description ? <p className="max-w-3xl text-sm text-muted-foreground">{description}</p> : null}
</div>
{children ? <div className="flex flex-wrap items-center gap-2">{children}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from "@/lib/utils";
export default function ResourceBar({ label, used = 0, total = 0, unit = "", percentage }) {
const computed = percentage ?? (total > 0 ? (used / total) * 100 : 0);
const safePercentage = Math.max(0, Math.min(100, Number.isFinite(computed) ? computed : 0));
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium">{label}</span>
<span className="font-mono text-[11px]">
{used}
{unit} / {total}
{unit}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted/80">
<div
className={cn("h-full rounded-full bg-primary/85 transition-all")}
style={{ width: `${safePercentage}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
const colorMap = {
primary: "text-primary bg-primary/12 ring-primary/20",
success: "text-emerald-700 bg-emerald-50 ring-emerald-200",
warning: "text-amber-700 bg-amber-50 ring-amber-200",
danger: "text-rose-700 bg-rose-50 ring-rose-200"
};
export default function StatCard({ icon: Icon, label, value, subtitle, trend, color = "primary" }) {
return (
<div className="surface-card p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.11em] text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-2xl font-semibold tracking-tight text-foreground">{value}</p>
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
{trend ? <p className="mt-2 inline-flex rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{trend}</p> : null}
</div>
{Icon ? (
<div className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ring-1", colorMap[color] ?? colorMap.primary)}>
<Icon className="h-4.5 w-4.5" />
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
const statusColors = {
running: "bg-emerald-50 text-emerald-700 border-emerald-200",
active: "bg-emerald-50 text-emerald-700 border-emerald-200",
online: "bg-emerald-50 text-emerald-700 border-emerald-200",
paid: "bg-emerald-50 text-emerald-700 border-emerald-200",
completed: "bg-emerald-50 text-emerald-700 border-emerald-200",
success: "bg-emerald-50 text-emerald-700 border-emerald-200",
pending: "bg-amber-50 text-amber-700 border-amber-200",
warning: "bg-amber-50 text-amber-700 border-amber-200",
stopped: "bg-slate-100 text-slate-700 border-slate-200",
offline: "bg-slate-100 text-slate-700 border-slate-200",
failed: "bg-rose-50 text-rose-700 border-rose-200",
critical: "bg-rose-50 text-rose-700 border-rose-200",
error: "bg-rose-50 text-rose-700 border-rose-200",
default: "bg-muted text-muted-foreground border-border"
};
export default function StatusBadge({ status, size = "sm" }) {
const normalized = String(status ?? "").toLowerCase();
const sizeClass = size === "lg" ? "px-2.5 py-1 text-xs" : "px-2 py-0.5 text-[11px]";
return (
<span
className={cn(
"inline-flex items-center rounded-full border font-semibold capitalize tracking-wide",
sizeClass,
statusColors[normalized] ?? statusColors.default
)}
>
{normalized || "unknown"}
</span>
);
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange);
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,97 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props} />
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props} />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props} />
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props} />
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,13 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Badge({ className, ...props }) {
return (
<span
className={cn("inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs font-medium", className)}
{...props}
/>
);
}
export { Badge };

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const variants = {
default: "border border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/95",
destructive: "border border-destructive bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-border bg-card text-foreground hover:bg-muted",
ghost: "border border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
secondary: "border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80"
};
const sizes = {
default: "h-10 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
lg: "h-11 px-6 text-sm",
icon: "h-10 w-10"
};
const Button = React.forwardRef(({ className, variant = "default", size = "default", type = "button", ...props }, ref) => {
return (
<button
ref={ref}
type={type}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-[background-color,color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-45 disabled:saturate-50 active:translate-y-px",
variants[variant] ?? variants.default,
sizes[size] ?? sizes.default,
className
)}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button };

View File

@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

193
src/components/ui/card.jsx Normal file
View File

@@ -0,0 +1,193 @@
import * as React from "react"
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const CarouselContext = React.createContext(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef((
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback((event) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
}, [scrollPrev, scrollNext])
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
};
}, [api, onSelect])
return (
(<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}>
{children}
</div>
</CarouselContext.Provider>)
);
})
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
(<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props} />
</div>)
);
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
(<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props} />)
);
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
(<Button
ref={ref}
variant={variant}
size={size}
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>)
);
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
(<Button
ref={ref}
variant={variant}
size={size}
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>)
);
})
CarouselNext.displayName = "CarouselNext"
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

View File

@@ -0,0 +1,309 @@
"use client";
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = {
light: "",
dark: ".dark"
}
const ChartContext = React.createContext(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
(<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>)
);
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({
id,
config
}) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (
(<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`)
.join("\n"),
}} />)
);
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef((
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
(<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>)
);
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
(<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
(<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor
}
} />
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>)
);
})}
</div>
</div>)
);
})
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef((
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
(<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
(<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}} />
)}
{itemConfig?.label}
</div>)
);
})}
</div>)
);
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config,
payload,
key
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey = key
if (
key in payload &&
typeof payload[key] === "string"
) {
configLabelKey = payload[key]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string"
) {
configLabelKey = payloadPayload[key]
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props} />
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({
children,
...props
}) => {
return (
(<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>)
);
}
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" data-cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props} />
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props} />
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props} />
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props} />
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const DialogContext = React.createContext(null);
function Dialog({ open, onOpenChange, children }) {
return <DialogContext.Provider value={{ open: Boolean(open), onOpenChange }}>{children}</DialogContext.Provider>;
}
function DialogContent({ className, children }) {
const ctx = React.useContext(DialogContext);
if (!ctx?.open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => ctx.onOpenChange?.(false)}>
<div className="absolute inset-0 bg-slate-950/40 backdrop-blur-[2px]" />
<div
className={cn(
"relative z-10 w-full max-w-2xl rounded-2xl border border-border bg-card p-6 shadow-[0_20px_45px_rgba(2,6,23,0.18)]",
className
)}
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>
);
}
function DialogHeader({ className, ...props }) {
return <div className={cn("mb-5 space-y-1", className)} {...props} />;
}
function DialogTitle({ className, ...props }) {
return <h2 className={cn("text-lg font-semibold tracking-tight text-foreground", className)} {...props} />;
}
function DialogFooter({ className, ...props }) {
return <div className={cn("mt-6 flex justify-end gap-2", className)} {...props} />;
}
export { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter };

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,134 @@
"use client";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
const FormFieldContext = React.createContext({})
const FormField = (
{
...props
}
) => {
return (
(<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>)
);
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const FormItemContext = React.createContext({})
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useId()
return (
(<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>)
);
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
(<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props} />)
);
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
(<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props} />)
);
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
(<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} />)
);
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
(<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}>
{body}
</p>)
);
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props} />
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
(<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}>
{char}
{hasFakeCaret && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>)
);
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(
"h-10 w-full rounded-lg border border-input bg-card/75 px-3 py-2 text-sm text-foreground shadow-[inset_0_1px_0_rgba(15,23,42,0.02)] placeholder:text-muted-foreground/90 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-primary/40 disabled:cursor-not-allowed disabled:opacity-55",
className
)}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,10 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef(({ className, ...props }, ref) => {
return <label ref={ref} className={cn("text-sm font-medium tracking-tight text-foreground", className)} {...props} />;
});
Label.displayName = "Label";
export { Label };

View File

@@ -0,0 +1,104 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props} />
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props} />
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props} />
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}>
<div
className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,100 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button";
const Pagination = ({
className,
...props
}) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props} />
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props} />
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)}
{...props} />
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
return (
(<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>)
);
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,42 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props} />
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}>
{withHandle && (
<div
className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,89 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const SelectContext = React.createContext(null);
function extractOptions(node, output = []) {
React.Children.forEach(node, (child) => {
if (!React.isValidElement(child)) return;
if (child.type?.displayName === "SelectItem") {
output.push({ value: child.props.value, label: child.props.children });
return;
}
if (child.props?.children) {
extractOptions(child.props.children, output);
}
});
return output;
}
function Select({ value, onValueChange, children }) {
const options = React.useMemo(() => extractOptions(children, []), [children]);
return (
<SelectContext.Provider value={{ value: value ?? "", onValueChange, options }}>
{children}
</SelectContext.Provider>
);
}
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => {
const ctx = React.useContext(SelectContext);
let placeholder;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type?.displayName === "SelectValue") {
placeholder = child.props.placeholder;
}
});
return (
<div className="relative">
<select
ref={ref}
className={cn(
"h-10 w-full appearance-none rounded-lg border border-input bg-card/75 px-3 py-2 pr-9 text-sm text-foreground shadow-[inset_0_1px_0_rgba(15,23,42,0.02)] transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-primary/40 disabled:cursor-not-allowed disabled:opacity-55",
className
)}
value={ctx?.value ?? ""}
onChange={(event) => ctx?.onValueChange?.(event.target.value)}
{...props}
>
{placeholder ? (
<option value="" disabled>
{placeholder}
</option>
) : null}
{(ctx?.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
);
});
SelectTrigger.displayName = "SelectTrigger";
function SelectValue() {
return null;
}
SelectValue.displayName = "SelectValue";
function SelectContent() {
return null;
}
SelectContent.displayName = "SelectContent";
function SelectItem() {
return null;
}
SelectItem.displayName = "SelectItem";
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };

View File

@@ -0,0 +1,109 @@
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

626
src/components/ui/sheet.jsx Normal file
View File

@@ -0,0 +1,626 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef((
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [setOpenProp, open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
return (
(<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style
}
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>)
);
})
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef((
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
(<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}>
{children}
</div>)
);
}
if (isMobile) {
return (
(<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>)
);
}
return (
(<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)} />
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow">
{children}
</div>
</div>
</div>)
);
})
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef(({ className, onClick, asChild = false, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
asChild={asChild}
{...props}>
{asChild ? (
<PanelLeft />
) : (
<>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</>
)}
</Button>)
);
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props} />)
);
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef(({ className, ...props }, ref) => {
return (
(<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props} />)
);
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props} />)
);
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} />)
);
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props} />)
);
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} />)
);
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
(<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props} />)
);
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props} />
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} />
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props} />
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef((
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} />
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
(<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip} />
</Tooltip>)
);
})
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props} />)
);
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, [])
return (
(<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>)
);
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef(
({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
}
)
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />)
);
}
export { Skeleton }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,29 @@
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
(<Sonner
theme={theme}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props} />)
);
}
export { Toaster }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef(({ className, checked = false, onCheckedChange, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-1",
checked ? "border-primary/40 bg-primary" : "border-border bg-muted",
className
)}
{...props}
>
<span
className={cn(
"inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-5" : "translate-x-0.5"
)}
/>
</button>
);
});
Switch.displayName = "Switch";
export { Switch };

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const TabsContext = React.createContext(null);
function Tabs({ value, defaultValue, onValueChange, className, children }) {
const [internal, setInternal] = React.useState(defaultValue ?? "");
const active = value ?? internal;
const setActive = (next) => {
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className={className}>{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ className, ...props }) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-xl border border-border/90 bg-muted/60 p-1",
className
)}
{...props}
/>
);
}
function TabsTrigger({ value, className, ...props }) {
const ctx = React.useContext(TabsContext);
const active = ctx?.active === value;
return (
<button
type="button"
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-[background-color,color,box-shadow]",
active
? "bg-card text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.08)]"
: "text-muted-foreground hover:text-foreground",
className
)}
onClick={() => ctx?.setActive?.(value)}
{...props}
/>
);
}
function TabsContent({ value, className, ...props }) {
const ctx = React.useContext(TabsContext);
if (ctx?.active !== value) return null;
return <div className={className} {...props} />;
}
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@@ -0,0 +1,105 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = React.forwardRef(({ ...props }, ref) => (
<div
ref={ref}
className="fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]"
{...props}
/>
));
ToastProvider.displayName = "ToastProvider";
const ToastViewport = React.forwardRef(({ ...props }, ref) => (
<div
ref={ref}
className="fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]"
{...props}
/>
));
ToastViewport.displayName = "ToastViewport";
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = "Toast";
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = "ToastAction";
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<button
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-rose-600 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
data-toast-close=""
{...props}
>
<X className="h-4 w-4" />
</button>
));
ToastClose.displayName = "ToastClose";
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = "ToastTitle";
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = "ToastDescription";
export {
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,23 @@
import { useToast } from "@/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<div className="fixed right-4 top-4 z-[100] space-y-2">
{toasts.map((toastItem) => (
<div
key={toastItem.id}
className={`min-w-[260px] max-w-sm rounded-xl border px-3.5 py-3 shadow-[0_10px_30px_rgba(15,23,42,0.12)] ${
{
destructive: "border-rose-200 bg-rose-50 text-rose-700"
}[toastItem.variant] ?? "border-border bg-card text-foreground"
}`}
>
{toastItem.title ? <p className="text-sm font-semibold">{toastItem.title}</p> : null}
{toastItem.description ? <p className="mt-1 text-xs text-muted-foreground">{toastItem.description}</p> : null}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props} />
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,164 @@
// Inspired by react-hot-toast library
import { useState, useEffect } from "react";
const TOAST_LIMIT = 20;
const TOAST_REMOVE_DELAY = 1000000;
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
const toastTimeouts = new Map();
const addToRemoveQueue = (toastId) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: actionTypes.REMOVE_TOAST,
toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
const _clearFromRemoveQueue = (toastId) => {
const timeout = toastTimeouts.get(toastId);
if (timeout) {
clearTimeout(timeout);
toastTimeouts.delete(toastId);
}
};
export const reducer = (state, action) => {
switch (action.type) {
case actionTypes.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case actionTypes.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case actionTypes.DISMISS_TOAST: {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case actionTypes.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners = [];
let memoryState = { toasts: [] };
function dispatch(action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
function toast({ ...props }) {
const id = genId();
const update = (props) =>
dispatch({
type: actionTypes.UPDATE_TOAST,
toast: { ...props, id },
});
const dismiss = () =>
dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });
dispatch({
type: actionTypes.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = useState(memoryState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
};
}
export { useToast, toast };

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
const TOAST_TIMEOUT = 3000;
let idCounter = 0;
let toasts = [];
const listeners = new Set();
function emit() {
listeners.forEach((listener) => listener(toasts));
}
function scheduleRemoval(id) {
window.setTimeout(() => {
toasts = toasts.filter((toastItem) => toastItem.id !== id);
emit();
}, TOAST_TIMEOUT);
}
export function toast({ title, description, variant = "default" }) {
const id = String(++idCounter);
const item = { id, title, description, variant };
toasts = [item, ...toasts].slice(0, 5);
emit();
scheduleRemoval(id);
return {
id,
dismiss: () => {
toasts = toasts.filter((toastItem) => toastItem.id !== id);
emit();
}
};
}
export function useToast() {
const [state, setState] = useState(toasts);
useEffect(() => {
listeners.add(setState);
return () => listeners.delete(setState);
}, []);
return {
toasts: state,
toast
};
}

19
src/hooks/use-mobile.jsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

201
src/index.css Normal file
View File

@@ -0,0 +1,201 @@
@import url("https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: "Public Sans", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--background: 210 33% 98%;
--foreground: 220 34% 16%;
--card: 0 0% 100%;
--card-foreground: 220 34% 16%;
--popover: 0 0% 100%;
--popover-foreground: 220 34% 16%;
--primary: 214 84% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 210 32% 95%;
--secondary-foreground: 220 25% 24%;
--muted: 210 26% 94%;
--muted-foreground: 215 15% 44%;
--accent: 207 90% 94%;
--accent-foreground: 215 64% 26%;
--destructive: 0 76% 48%;
--destructive-foreground: 0 0% 100%;
--border: 214 22% 86%;
--input: 214 22% 82%;
--ring: 214 84% 45%;
--chart-1: 214 84% 45%;
--chart-2: 193 79% 40%;
--chart-3: 27 92% 46%;
--chart-4: 143 72% 38%;
--chart-5: 0 76% 48%;
--sidebar-background: 210 33% 97%;
--sidebar-foreground: 220 23% 23%;
--sidebar-primary: 214 84% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 28% 92%;
--sidebar-accent-foreground: 220 34% 16%;
--sidebar-border: 214 24% 84%;
--sidebar-ring: 214 84% 45%;
--success: 145 70% 34%;
--warning: 32 96% 44%;
--radius: 0.8rem;
}
.dark {
--background: 222 34% 10%;
--foreground: 210 22% 95%;
--card: 222 33% 13%;
--card-foreground: 210 22% 95%;
--popover: 222 33% 13%;
--popover-foreground: 210 22% 95%;
--primary: 212 100% 64%;
--primary-foreground: 222 34% 10%;
--secondary: 222 24% 17%;
--secondary-foreground: 210 22% 90%;
--muted: 223 22% 16%;
--muted-foreground: 216 14% 71%;
--accent: 212 62% 25%;
--accent-foreground: 210 22% 95%;
--destructive: 0 79% 59%;
--destructive-foreground: 0 0% 98%;
--border: 222 18% 22%;
--input: 222 18% 24%;
--ring: 212 100% 64%;
--chart-1: 212 100% 64%;
--chart-2: 190 90% 54%;
--chart-3: 34 90% 57%;
--chart-4: 143 74% 47%;
--chart-5: 0 79% 59%;
--sidebar-background: 222 34% 11%;
--sidebar-foreground: 213 20% 82%;
--sidebar-primary: 212 100% 64%;
--sidebar-primary-foreground: 222 34% 10%;
--sidebar-accent: 222 22% 16%;
--sidebar-accent-foreground: 210 22% 95%;
--sidebar-border: 222 18% 22%;
--sidebar-ring: 212 100% 64%;
}
}
@layer base {
* {
@apply border-border outline-ring/40;
}
html,
body,
#root {
min-height: 100%;
}
body {
@apply bg-background text-foreground font-sans antialiased;
background-image:
radial-gradient(circle at 10% -10%, hsl(214 84% 45% / 0.08), transparent 45%),
radial-gradient(circle at 100% 0%, hsl(193 79% 40% / 0.08), transparent 35%);
}
}
@layer components {
.app-shell-bg {
background-image:
linear-gradient(to bottom, hsl(var(--background)), hsl(var(--background))),
radial-gradient(circle at 80% 0%, hsl(var(--primary) / 0.07), transparent 35%);
}
.surface-card {
@apply rounded-xl border border-border/90 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.06)] transition-shadow;
}
.surface-card-quiet {
@apply rounded-xl border border-border/80 bg-card/75;
}
.chart-panel {
@apply rounded-xl border border-border/80 bg-gradient-to-b from-card to-card/90 p-4;
}
.panel-head {
@apply mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between;
}
.panel-title {
@apply text-sm font-semibold tracking-tight text-foreground;
}
.panel-subtitle {
@apply text-[11px] text-muted-foreground;
}
.data-grid-wrap {
@apply overflow-x-auto rounded-xl border border-border/85 bg-card;
}
.data-grid-table {
@apply w-full border-collapse;
}
.data-grid-table thead th {
@apply whitespace-nowrap border-b border-border bg-muted/40 px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground;
}
.data-grid-table tbody td {
@apply px-4 py-3 text-sm text-foreground;
}
.data-grid-table tbody tr {
@apply border-b border-border/70 transition-colors;
}
.data-grid-table tbody tr:hover {
@apply bg-muted/35;
}
.kpi-chip {
@apply inline-flex items-center rounded-full border border-border/80 bg-muted/55 px-2.5 py-1 text-[11px] font-medium text-muted-foreground;
}
.page-enter {
animation: page-enter 420ms ease-out both;
}
}
@keyframes page-enter {
from {
opacity: 0;
transform: translate3d(0, 12px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb {
background: hsl(215 16% 70%);
border: 2px solid hsl(var(--muted));
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(215 16% 58%);
}

77
src/lib/AuthContext.jsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from "react";
import { appClient } from "@/api/appClient";
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoadingAuth, setIsLoadingAuth] = useState(true);
const [isLoadingPublicSettings, setIsLoadingPublicSettings] = useState(false);
const [authError, setAuthError] = useState(null);
const checkAppState = useCallback(async () => {
try {
setIsLoadingAuth(true);
setAuthError(null);
const currentUser = await appClient.auth.me();
setUser(currentUser);
setIsAuthenticated(true);
} catch (error) {
setUser(null);
setIsAuthenticated(false);
setAuthError({
type: "auth_required",
message: error?.message || "Authentication required"
});
} finally {
setIsLoadingAuth(false);
}
}, []);
useEffect(() => {
checkAppState();
}, [checkAppState]);
const logout = useCallback((shouldRedirect = true) => {
setUser(null);
setIsAuthenticated(false);
setAuthError({ type: "auth_required", message: "Logged out" });
if (shouldRedirect) {
appClient.auth.logout("/");
} else {
appClient.auth.logout();
}
}, []);
const navigateToLogin = useCallback(() => {
appClient.auth.redirectToLogin(window.location.href);
}, []);
return (
<AuthContext.Provider
value={{
user,
isAuthenticated,
isLoadingAuth,
isLoadingPublicSettings,
authError,
appPublicSettings: null,
logout,
navigateToLogin,
checkAppState
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

53
src/lib/PageNotFound.jsx Normal file
View File

@@ -0,0 +1,53 @@
import { useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { appClient } from "@/api/appClient";
export default function PageNotFound() {
const location = useLocation();
const pageName = location.pathname.substring(1) || "unknown";
const { data: authData, isFetched } = useQuery({
queryKey: ["user"],
queryFn: async () => {
try {
const user = await appClient.auth.me();
return { user, isAuthenticated: true };
} catch {
return { user: null, isAuthenticated: false };
}
}
});
return (
<div className="app-shell-bg flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-lg rounded-2xl border border-border bg-card p-8 text-center shadow-[0_12px_40px_rgba(15,23,42,0.12)]">
<div className="space-y-3">
<h1 className="text-6xl font-semibold tracking-tight text-foreground">404</h1>
<p className="text-lg font-medium text-foreground">Page Not Found</p>
<p className="text-sm text-muted-foreground">
The route <span className="font-semibold text-foreground">/{pageName}</span> does not exist in this workspace.
</p>
</div>
{isFetched && authData.isAuthenticated && authData.user?.role === "admin" && (
<div className="mt-6 rounded-xl border border-amber-200 bg-amber-50 p-4 text-left">
<p className="text-sm font-semibold text-amber-700">Admin Note</p>
<p className="mt-1 text-sm text-amber-700/90">
This page may not be implemented yet. Ask the assistant to add the missing route and screen.
</p>
</div>
)}
<button
type="button"
onClick={() => {
window.location.href = "/";
}}
className="mt-7 inline-flex items-center rounded-lg border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted"
>
Go Home
</button>
</div>
</div>
);
}

54
src/lib/app-params.js Normal file
View File

@@ -0,0 +1,54 @@
const isNode = typeof window === 'undefined';
const windowObj = isNode ? { localStorage: new Map() } : window;
const storage = windowObj.localStorage;
const toSnakeCase = (str) => {
return str.replace(/([A-Z])/g, '_$1').toLowerCase();
}
const getAppParamValue = (paramName, { defaultValue = undefined, removeFromUrl = false } = {}) => {
if (isNode) {
return defaultValue;
}
const storageKey = `base44_${toSnakeCase(paramName)}`;
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get(paramName);
if (removeFromUrl) {
urlParams.delete(paramName);
const newUrl = `${window.location.pathname}${urlParams.toString() ? `?${urlParams.toString()}` : ""
}${window.location.hash}`;
window.history.replaceState({}, document.title, newUrl);
}
if (searchParam) {
storage.setItem(storageKey, searchParam);
return searchParam;
}
if (defaultValue) {
storage.setItem(storageKey, defaultValue);
return defaultValue;
}
const storedValue = storage.getItem(storageKey);
if (storedValue) {
return storedValue;
}
return null;
}
const getAppParams = () => {
if (getAppParamValue("clear_access_token") === 'true') {
storage.removeItem('base44_access_token');
storage.removeItem('token');
}
return {
appId: getAppParamValue("app_id", { defaultValue: import.meta.env.VITE_BASE44_APP_ID }),
token: getAppParamValue("access_token", { removeFromUrl: true }),
fromUrl: getAppParamValue("from_url", { defaultValue: window.location.href }),
functionsVersion: getAppParamValue("functions_version", { defaultValue: import.meta.env.VITE_BASE44_FUNCTIONS_VERSION }),
appBaseUrl: getAppParamValue("app_base_url", { defaultValue: import.meta.env.VITE_BASE44_APP_BASE_URL }),
}
}
export const appParams = {
...getAppParams()
}

11
src/lib/query-client.js Normal file
View File

@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClientInstance = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});

9
src/lib/utils.js Normal file
View File

@@ -0,0 +1,9 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
export const isIframe = window.self !== window.top;

8
src/main.jsx Normal file
View File

@@ -0,0 +1,8 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App.jsx'
import '@/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
)

88
src/pages/AuditLogs.jsx Normal file
View File

@@ -0,0 +1,88 @@
import { useState, useEffect } from "react";
import { appClient } from "@/api/appClient";
import { FileText, Search, Filter } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import moment from "moment";
export default function AuditLogs() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [severityFilter, setSeverityFilter] = useState("all");
useEffect(() => {
appClient.entities.AuditLog.list("-created_date", 100).then(data => { setLogs(data); setLoading(false); });
}, []);
const filtered = logs.filter(l => {
const matchSearch = (l.action || "").toLowerCase().includes(search.toLowerCase()) ||
(l.actor_email || "").toLowerCase().includes(search.toLowerCase()) ||
(l.resource_name || "").toLowerCase().includes(search.toLowerCase());
const matchSeverity = severityFilter === "all" || l.severity === severityFilter;
return matchSearch && matchSeverity;
});
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
return (
<div className="space-y-6">
<PageHeader title="Audit Logs" description={`${logs.length} events recorded`} />
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input placeholder="Search logs..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9 bg-card border-border" />
</div>
<Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="w-[160px] bg-card border-border">
<Filter className="w-3.5 h-3.5 mr-2 text-muted-foreground" /><SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Severity</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
</div>
{filtered.length === 0 ? (
<EmptyState icon={FileText} title="No Audit Logs" description="Activity will be recorded here." />
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{["Time", "Severity", "Action", "Resource", "Actor", "Details"].map(h => (
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map(log => (
<tr key={log.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-xs text-muted-foreground font-mono whitespace-nowrap">{log.created_date ? moment(log.created_date).format("MMM D, HH:mm:ss") : "—"}</td>
<td className="px-4 py-3"><StatusBadge status={log.severity} /></td>
<td className="px-4 py-3 text-sm font-medium text-foreground">{log.action}</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground">{log.resource_name || "—"}</span>
<p className="text-[11px] text-muted-foreground capitalize">{log.resource_type}</p>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{log.actor_email}</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{log.details || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

231
src/pages/Backups.jsx Normal file
View File

@@ -0,0 +1,231 @@
import { useEffect, useState } from "react";
import moment from "moment";
import { Plus, RefreshCw, PlayCircle, Trash2, Shield, ShieldOff } from "lucide-react";
import { appClient } from "@/api/appClient";
import PageHeader from "../components/shared/PageHeader";
import EmptyState from "../components/shared/EmptyState";
import StatusBadge from "../components/shared/StatusBadge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/use-toast";
export default function Backups() {
const [loading, setLoading] = useState(true);
const [backups, setBackups] = useState([]);
const [restores, setRestores] = useState([]);
const [snapshots, setSnapshots] = useState([]);
const [vms, setVms] = useState([]);
const [showBackup, setShowBackup] = useState(false);
const [showRestore, setShowRestore] = useState(false);
const [showSnapshot, setShowSnapshot] = useState(false);
const [busy, setBusy] = useState(false);
const [backupForm, setBackupForm] = useState({ vm_id: "", type: "FULL", source: "LOCAL", schedule: "MANUAL", retention_days: 7, storage: "local-lvm" });
const [restoreForm, setRestoreForm] = useState({ backup_id: "", target_vm_id: "", mode: "FULL_VM", requested_files: "", pbs_enabled: false, run_immediately: true });
const [snapshotForm, setSnapshotForm] = useState({ vm_id: "", name: "", frequency: "DAILY", interval: 1, day_of_week: 0, hour_utc: 2, minute_utc: 0, retention: 7 });
const { toast } = useToast();
const loadData = async () => {
setLoading(true);
try {
const [b, r, s, vm] = await Promise.all([
appClient.backups.listBackups({ limit: 200 }),
appClient.backups.listRestoreTasks({ limit: 200 }),
appClient.backups.listSnapshotJobs(),
appClient.entities.VirtualMachine.list("-created_at", 200)
]);
setBackups(b?.data || []);
setRestores(r?.data || []);
setSnapshots(s?.data || []);
setVms(vm || []);
if (!backupForm.vm_id && (vm || []).length > 0) setBackupForm((p) => ({ ...p, vm_id: vm[0].id }));
if (!snapshotForm.vm_id && (vm || []).length > 0) setSnapshotForm((p) => ({ ...p, vm_id: vm[0].id, name: `${vm[0].name} Snapshot` }));
} catch (error) {
toast({ title: "Load Failed", description: error?.message || "Could not load backups", variant: "destructive" });
} finally {
setLoading(false);
}
};
useEffect(() => { loadData(); }, []);
const createBackup = async () => {
setBusy(true);
try {
await appClient.backups.createBackup({ ...backupForm, retention_days: Number(backupForm.retention_days) });
setShowBackup(false);
toast({ title: "Backup Created" });
await loadData();
} catch (error) {
toast({ title: "Create Failed", description: error?.message || "Could not create backup", variant: "destructive" });
} finally { setBusy(false); }
};
const createRestore = async () => {
setBusy(true);
try {
const files = restoreForm.requested_files.split(/\r?\n|,/).map((x) => x.trim()).filter(Boolean);
await appClient.backups.createRestoreTask({
backup_id: restoreForm.backup_id,
target_vm_id: restoreForm.target_vm_id || undefined,
mode: restoreForm.mode,
requested_files: restoreForm.mode === "FULL_VM" ? undefined : files,
pbs_enabled: restoreForm.pbs_enabled,
run_immediately: restoreForm.run_immediately
});
setShowRestore(false);
toast({ title: "Restore Task Created" });
await loadData();
} catch (error) {
toast({ title: "Restore Failed", description: error?.message || "Could not create restore task", variant: "destructive" });
} finally { setBusy(false); }
};
const createSnapshotJob = async () => {
setBusy(true);
try {
await appClient.backups.createSnapshotJob({
...snapshotForm,
interval: Number(snapshotForm.interval),
day_of_week: snapshotForm.frequency === "WEEKLY" ? Number(snapshotForm.day_of_week) : undefined,
hour_utc: Number(snapshotForm.hour_utc),
minute_utc: Number(snapshotForm.minute_utc),
retention: Number(snapshotForm.retention)
});
setShowSnapshot(false);
toast({ title: "Snapshot Job Created" });
await loadData();
} catch (error) {
toast({ title: "Snapshot Failed", description: error?.message || "Could not create snapshot job", variant: "destructive" });
} finally { setBusy(false); }
};
const toggleProtection = async (backup) => {
try {
await appClient.backups.setBackupProtection(backup.id, !backup.is_protected);
await loadData();
} catch (error) {
toast({ title: "Update Failed", description: error?.message || "Could not update protection", variant: "destructive" });
}
};
const deleteBackup = async (backup) => {
try {
await appClient.backups.deleteBackup(backup.id);
await loadData();
} catch {
try {
await appClient.backups.deleteBackup(backup.id, { force: true });
await loadData();
} catch (error) {
toast({ title: "Delete Failed", description: error?.message || "Could not delete backup", variant: "destructive" });
}
}
};
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
return (
<div className="space-y-6">
<PageHeader title="Backups & Restore" description="Phase 3 backup policies, restore queue and snapshot automation">
<div className="flex gap-2">
<Button variant="outline" className="gap-2" onClick={loadData}><RefreshCw className="w-4 h-4" /> Refresh</Button>
<Button className="gap-2" onClick={() => setShowBackup(true)}><Plus className="w-4 h-4" /> New Backup</Button>
</div>
</PageHeader>
<Tabs defaultValue="backups" className="space-y-4">
<TabsList className="bg-muted">
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="restores">Restore Tasks</TabsTrigger>
<TabsTrigger value="snapshots">Snapshot Jobs</TabsTrigger>
</TabsList>
<TabsContent value="backups">
{backups.length === 0 ? <EmptyState title="No Backups" description="Create your first backup." /> : (
<div className="surface-card overflow-hidden"><div className="overflow-x-auto"><table className="w-full">
<thead><tr className="border-b border-border bg-muted/30">{["VM", "Type", "Source", "Status", "Schedule", "Created", "Actions"].map((h) => <th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-border">{backups.map((b) => <tr key={b.id} className="hover:bg-muted/20">
<td className="px-4 py-3"><span className="text-sm font-medium text-foreground">{b.vm_name}</span><p className="text-[11px] text-muted-foreground">{b.node}</p></td>
<td className="px-4 py-3 text-xs font-mono">{b.type}</td>
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">{b.source}</td>
<td className="px-4 py-3"><StatusBadge status={(b.status || "").toLowerCase()} /></td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.schedule}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created_at ? moment(b.created_at).fromNow() : "-"}</td>
<td className="px-4 py-3"><div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-400 hover:bg-amber-50" onClick={() => toggleProtection(b)}>{b.is_protected ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-rose-600 hover:bg-rose-50" onClick={() => deleteBackup(b)}><Trash2 className="w-4 h-4" /></Button>
</div></td>
</tr>)}</tbody>
</table></div></div>
)}
</TabsContent>
<TabsContent value="restores" className="space-y-3">
<div className="flex justify-end"><Button className="gap-2" onClick={() => setShowRestore(true)}><Plus className="w-4 h-4" /> New Restore</Button></div>
{restores.length === 0 ? <EmptyState title="No Restore Tasks" description="Create a restore task from any owned backup." /> : (
<div className="surface-card overflow-hidden"><div className="overflow-x-auto"><table className="w-full">
<thead><tr className="border-b border-border bg-muted/30">{["Mode", "Source VM", "Target VM", "Status", "Created", "Run"].map((h) => <th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-border">{restores.map((r) => <tr key={r.id} className="hover:bg-muted/20">
<td className="px-4 py-3 text-xs font-mono">{r.mode}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{r.source_vm?.name}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{r.target_vm?.name}</td>
<td className="px-4 py-3"><StatusBadge status={(r.status || "").toLowerCase()} /></td>
<td className="px-4 py-3 text-xs text-muted-foreground">{r.created_at ? moment(r.created_at).fromNow() : "-"}</td>
<td className="px-4 py-3">{(r.status === "PENDING" || r.status === "FAILED") && <Button variant="ghost" size="icon" className="h-8 w-8 text-emerald-600 hover:bg-emerald-50" onClick={async () => { await appClient.backups.runRestoreTask(r.id); await loadData(); }}><PlayCircle className="w-4 h-4" /></Button>}</td>
</tr>)}</tbody>
</table></div></div>
)}
</TabsContent>
<TabsContent value="snapshots" className="space-y-3">
<div className="flex justify-end"><Button className="gap-2" onClick={() => setShowSnapshot(true)}><Plus className="w-4 h-4" /> New Snapshot Job</Button></div>
{snapshots.length === 0 ? <EmptyState title="No Snapshot Jobs" description="Create recurring snapshot jobs with retention." /> : (
<div className="surface-card overflow-hidden"><div className="overflow-x-auto"><table className="w-full">
<thead><tr className="border-b border-border bg-muted/30">{["Name", "VM", "Frequency", "Next Run", "Status", "Actions"].map((h) => <th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>)}</tr></thead>
<tbody className="divide-y divide-border">{snapshots.map((s) => <tr key={s.id} className="hover:bg-muted/20">
<td className="px-4 py-3 text-sm font-medium">{s.name}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{s.vm?.name}</td>
<td className="px-4 py-3 text-xs font-mono">{s.frequency}/{s.interval}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{s.next_run_at ? moment(s.next_run_at).fromNow() : "-"}</td>
<td className="px-4 py-3"><StatusBadge status={s.enabled ? "active" : "stopped"} /></td>
<td className="px-4 py-3"><div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-emerald-600 hover:bg-emerald-50" onClick={async () => { await appClient.backups.runSnapshotJob(s.id); await loadData(); }}><PlayCircle className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-400 hover:bg-amber-50" onClick={async () => { await appClient.backups.updateSnapshotJob(s.id, { enabled: !s.enabled }); await loadData(); }}>{s.enabled ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-rose-600 hover:bg-rose-50" onClick={async () => { await appClient.backups.deleteSnapshotJob(s.id); await loadData(); }}><Trash2 className="w-4 h-4" /></Button>
</div></td>
</tr>)}</tbody>
</table></div></div>
)}
</TabsContent>
</Tabs>
<Dialog open={showBackup} onOpenChange={setShowBackup}><DialogContent className="bg-card border-border max-w-lg"><DialogHeader><DialogTitle>Create Backup</DialogTitle></DialogHeader><div className="space-y-3">
<div><Label>VM</Label><Select value={backupForm.vm_id} onValueChange={(v) => setBackupForm((p) => ({ ...p, vm_id: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent></Select></div>
<div className="grid grid-cols-2 gap-3"><div><Label>Type</Label><Select value={backupForm.type} onValueChange={(v) => setBackupForm((p) => ({ ...p, type: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{["FULL", "INCREMENTAL", "SNAPSHOT"].map((x) => <SelectItem key={x} value={x}>{x}</SelectItem>)}</SelectContent></Select></div><div><Label>Source</Label><Select value={backupForm.source} onValueChange={(v) => setBackupForm((p) => ({ ...p, source: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{["LOCAL", "PBS", "REMOTE"].map((x) => <SelectItem key={x} value={x}>{x}</SelectItem>)}</SelectContent></Select></div></div>
<div className="grid grid-cols-2 gap-3"><div><Label>Schedule</Label><Select value={backupForm.schedule} onValueChange={(v) => setBackupForm((p) => ({ ...p, schedule: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{["MANUAL", "DAILY", "WEEKLY", "MONTHLY"].map((x) => <SelectItem key={x} value={x}>{x}</SelectItem>)}</SelectContent></Select></div><div><Label>Retention</Label><Input type="number" min={1} value={backupForm.retention_days} onChange={(e) => setBackupForm((p) => ({ ...p, retention_days: Number(e.target.value) }))} className="bg-muted border-border mt-1" /></div></div>
</div><DialogFooter><Button variant="outline" onClick={() => setShowBackup(false)}>Cancel</Button><Button onClick={createBackup} disabled={busy || !backupForm.vm_id}>{busy ? "Saving..." : "Create"}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={showRestore} onOpenChange={setShowRestore}><DialogContent className="bg-card border-border max-w-lg"><DialogHeader><DialogTitle>Create Restore Task</DialogTitle></DialogHeader><div className="space-y-3">
<div><Label>Backup</Label><Select value={restoreForm.backup_id} onValueChange={(v) => setRestoreForm((p) => ({ ...p, backup_id: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{backups.map((b) => <SelectItem key={b.id} value={b.id}>{b.vm_name} - {b.type}</SelectItem>)}</SelectContent></Select></div>
<div className="grid grid-cols-2 gap-3"><div><Label>Mode</Label><Select value={restoreForm.mode} onValueChange={(v) => setRestoreForm((p) => ({ ...p, mode: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{["FULL_VM", "FILES", "SINGLE_FILE"].map((x) => <SelectItem key={x} value={x}>{x}</SelectItem>)}</SelectContent></Select></div><div><Label>Target VM</Label><Select value={restoreForm.target_vm_id || "same"} onValueChange={(v) => setRestoreForm((p) => ({ ...p, target_vm_id: v === "same" ? "" : v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="same">Same VM</SelectItem>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent></Select></div></div>
{restoreForm.mode !== "FULL_VM" && <div><Label>Files</Label><Input value={restoreForm.requested_files} onChange={(e) => setRestoreForm((p) => ({ ...p, requested_files: e.target.value }))} className="bg-muted border-border mt-1" placeholder="/etc/hosts,/var/www/index.html" /></div>}
</div><DialogFooter><Button variant="outline" onClick={() => setShowRestore(false)}>Cancel</Button><Button onClick={createRestore} disabled={busy || !restoreForm.backup_id}>{busy ? "Saving..." : "Create"}</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={showSnapshot} onOpenChange={setShowSnapshot}><DialogContent className="bg-card border-border max-w-lg"><DialogHeader><DialogTitle>Create Snapshot Job</DialogTitle></DialogHeader><div className="space-y-3">
<div><Label>VM</Label><Select value={snapshotForm.vm_id} onValueChange={(v) => setSnapshotForm((p) => ({ ...p, vm_id: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent></Select></div>
<div><Label>Name</Label><Input value={snapshotForm.name} onChange={(e) => setSnapshotForm((p) => ({ ...p, name: e.target.value }))} className="bg-muted border-border mt-1" /></div>
<div className="grid grid-cols-2 gap-3"><div><Label>Frequency</Label><Select value={snapshotForm.frequency} onValueChange={(v) => setSnapshotForm((p) => ({ ...p, frequency: v }))}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent>{["HOURLY", "DAILY", "WEEKLY"].map((x) => <SelectItem key={x} value={x}>{x}</SelectItem>)}</SelectContent></Select></div><div><Label>Retention</Label><Input type="number" min={1} value={snapshotForm.retention} onChange={(e) => setSnapshotForm((p) => ({ ...p, retention: Number(e.target.value) }))} className="bg-muted border-border mt-1" /></div></div>
</div><DialogFooter><Button variant="outline" onClick={() => setShowSnapshot(false)}>Cancel</Button><Button onClick={createSnapshotJob} disabled={busy || !snapshotForm.vm_id || !snapshotForm.name}>{busy ? "Saving..." : "Create"}</Button></DialogFooter></DialogContent></Dialog>
</div>
);
}

323
src/pages/Billing.jsx Normal file
View File

@@ -0,0 +1,323 @@
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { appClient } from "@/api/appClient";
import { CreditCard, DollarSign, Plus, Receipt, TrendingUp } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import StatCard from "../components/shared/StatCard";
import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
function formatCurrency(amount, currency = "NGN") {
try {
return new Intl.NumberFormat("en-NG", { style: "currency", currency, maximumFractionDigits: 0 }).format(Number(amount || 0));
} catch {
return `${currency} ${Number(amount || 0).toLocaleString()}`;
}
}
export default function Billing() {
const [invoices, setInvoices] = useState([]);
const [plans, setPlans] = useState([]);
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateInvoice, setShowCreateInvoice] = useState(false);
const [showCreatePlan, setShowCreatePlan] = useState(false);
const [creating, setCreating] = useState(false);
const [invoiceForm, setInvoiceForm] = useState({ tenant_id: "", amount: 0, currency: "NGN", due_date: "", payment_provider: "paystack" });
const [planForm, setPlanForm] = useState({ name: "", price_monthly: 0, currency: "NGN", cpu_cores: 2, ram_mb: 2048, disk_gb: 40, bandwidth_gb: 1000, is_active: true });
const { toast } = useToast();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
const [inv, pl, ten] = await Promise.all([
appClient.entities.Invoice.list("-created_date"),
appClient.entities.BillingPlan.list(),
appClient.entities.Tenant.list()
]);
setInvoices(inv);
setPlans(pl);
setTenants(ten);
setLoading(false);
};
const totalRevenue = useMemo(() => invoices.filter((invoice) => invoice.status === "paid").reduce((sum, invoice) => sum + (invoice.amount || 0), 0), [invoices]);
const pendingAmount = useMemo(() => invoices.filter((invoice) => invoice.status === "pending").reduce((sum, invoice) => sum + (invoice.amount || 0), 0), [invoices]);
const overdueCount = useMemo(() => invoices.filter((invoice) => invoice.status === "overdue").length, [invoices]);
const paidCount = useMemo(() => invoices.filter((invoice) => invoice.status === "paid").length, [invoices]);
const collectionRate = invoices.length > 0 ? Math.round((paidCount / invoices.length) * 100) : 0;
const handleCreateInvoice = async () => {
if (!invoiceForm.tenant_id) return;
setCreating(true);
const tenant = tenants.find((item) => item.id === invoiceForm.tenant_id);
await appClient.entities.Invoice.create({
...invoiceForm,
status: "pending",
invoice_number: `INV-${Date.now().toString(36).toUpperCase()}`,
tenant_name: tenant?.name || "Unknown"
});
await loadData();
setShowCreateInvoice(false);
setCreating(false);
toast({ title: "Invoice Created" });
};
const handleCreatePlan = async () => {
if (!planForm.name) return;
setCreating(true);
await appClient.entities.BillingPlan.create({ ...planForm, slug: planForm.name.toLowerCase().replace(/\s+/g, "-") });
await loadData();
setShowCreatePlan(false);
setCreating(false);
toast({ title: "Plan Created" });
};
const markPaid = async (invoice) => {
await appClient.entities.Invoice.update(invoice.id, {
status: "paid",
paid_date: new Date().toISOString().split("T")[0],
payment_reference: `PAY-${Date.now().toString(36).toUpperCase()}`
});
await loadData();
toast({ title: "Invoice Paid", description: invoice.invoice_number });
};
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Billing" description="Manage invoices, plans, and payment operations" />
<div className="flex flex-wrap items-center gap-2">
<span className="kpi-chip">Collection Rate: {collectionRate}%</span>
<span className="kpi-chip">Paid Invoices: {paidCount}</span>
<span className="kpi-chip">Pending Invoices: {invoices.filter((item) => item.status === "pending").length}</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard icon={DollarSign} label="Total Revenue" value={formatCurrency(totalRevenue)} />
<StatCard icon={Receipt} label="Pending" value={formatCurrency(pendingAmount)} color="warning" />
<StatCard icon={CreditCard} label="Invoices" value={invoices.length} />
<StatCard icon={TrendingUp} label="Overdue" value={overdueCount} color="danger" />
</div>
<Tabs defaultValue="invoices" className="space-y-4">
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="invoices" className="flex-1 sm:flex-none">Invoices</TabsTrigger>
<TabsTrigger value="plans" className="flex-1 sm:flex-none">Billing Plans</TabsTrigger>
</TabsList>
<div className="flex flex-wrap gap-2">
<Button onClick={() => setShowCreateInvoice(true)} variant="outline" className="flex-1 gap-2 text-sm sm:flex-none">
<Plus className="h-3.5 w-3.5" />
Invoice
</Button>
<Button onClick={() => setShowCreatePlan(true)} className="flex-1 gap-2 text-sm sm:flex-none">
<Plus className="h-3.5 w-3.5" />
Plan
</Button>
</div>
</div>
<TabsContent value="invoices">
{invoices.length === 0 ? (
<EmptyState icon={Receipt} title="No Invoices" description="Create your first invoice." />
) : (
<div className="data-grid-wrap">
<table className="data-grid-table">
<thead>
<tr>
<th>Invoice</th>
<th>Tenant</th>
<th>Amount</th>
<th>Status</th>
<th>Due Date</th>
<th>Provider</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td className="font-mono font-medium">{invoice.invoice_number}</td>
<td className="text-muted-foreground">{invoice.tenant_name}</td>
<td className="font-medium">{formatCurrency(invoice.amount, invoice.currency)}</td>
<td><StatusBadge status={invoice.status} /></td>
<td className="font-mono text-muted-foreground">{invoice.due_date || "-"}</td>
<td className="capitalize text-muted-foreground">{invoice.payment_provider || "-"}</td>
<td>
<div className="flex justify-end">
{invoice.status === "pending" ? (
<Button variant="ghost" size="sm" className="text-emerald-600 hover:bg-emerald-50 text-xs" onClick={() => markPaid(invoice)}>
Mark Paid
</Button>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
<TabsContent value="plans">
{plans.length === 0 ? (
<EmptyState icon={CreditCard} title="No Plans" description="Create billing plans for your services." />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan, index) => (
<motion.div
key={plan.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04 }}
className="chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">{plan.name}</h3>
<p className="panel-subtitle">{plan.slug || "custom-plan"}</p>
</div>
<StatusBadge status={plan.is_active ? "active" : "stopped"} />
</div>
<p className="text-2xl font-semibold tracking-tight text-primary">
{formatCurrency(plan.price_monthly, plan.currency)}
<span className="ml-1 text-sm font-normal text-muted-foreground">/month</span>
</p>
<div className="mt-4 space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between"><span>CPU</span><span className="text-foreground">{plan.cpu_cores} cores</span></div>
<div className="flex justify-between"><span>RAM</span><span className="text-foreground">{plan.ram_mb} MB</span></div>
<div className="flex justify-between"><span>Disk</span><span className="text-foreground">{plan.disk_gb} GB</span></div>
<div className="flex justify-between"><span>Bandwidth</span><span className="text-foreground">{plan.bandwidth_gb || "Unlimited"} GB</span></div>
</div>
</motion.div>
))}
</div>
)}
</TabsContent>
</Tabs>
<Dialog open={showCreateInvoice} onOpenChange={setShowCreateInvoice}>
<DialogContent>
<DialogHeader><DialogTitle>Create Invoice</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>Tenant</Label>
<Select value={invoiceForm.tenant_id} onValueChange={(value) => setInvoiceForm({ ...invoiceForm, tenant_id: value })}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Select tenant" /></SelectTrigger>
<SelectContent>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label>Amount</Label>
<Input type="number" value={invoiceForm.amount} onChange={(event) => setInvoiceForm({ ...invoiceForm, amount: Number(event.target.value) })} className="mt-1" />
</div>
<div>
<Label>Currency</Label>
<Select value={invoiceForm.currency} onValueChange={(value) => setInvoiceForm({ ...invoiceForm, currency: value })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="NGN">NGN</SelectItem>
<SelectItem value="USD">USD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label>Due Date</Label>
<Input type="date" value={invoiceForm.due_date} onChange={(event) => setInvoiceForm({ ...invoiceForm, due_date: event.target.value })} className="mt-1" />
</div>
<div>
<Label>Provider</Label>
<Select value={invoiceForm.payment_provider} onValueChange={(value) => setInvoiceForm({ ...invoiceForm, payment_provider: value })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="paystack">Paystack</SelectItem>
<SelectItem value="flutterwave">Flutterwave</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateInvoice(false)}>Cancel</Button>
<Button onClick={handleCreateInvoice} disabled={creating || !invoiceForm.tenant_id}>
{creating ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreatePlan} onOpenChange={setShowCreatePlan}>
<DialogContent>
<DialogHeader><DialogTitle>Create Billing Plan</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>Plan Name</Label>
<Input value={planForm.name} onChange={(event) => setPlanForm({ ...planForm, name: event.target.value })} placeholder="e.g. Starter VPS" className="mt-1" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label>Monthly Price</Label>
<Input type="number" value={planForm.price_monthly} onChange={(event) => setPlanForm({ ...planForm, price_monthly: Number(event.target.value) })} className="mt-1" />
</div>
<div>
<Label>Currency</Label>
<Select value={planForm.currency} onValueChange={(value) => setPlanForm({ ...planForm, currency: value })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="NGN">NGN</SelectItem>
<SelectItem value="USD">USD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<Label>CPU Cores</Label>
<Input type="number" value={planForm.cpu_cores} onChange={(event) => setPlanForm({ ...planForm, cpu_cores: Number(event.target.value) })} className="mt-1" />
</div>
<div>
<Label>RAM (MB)</Label>
<Input type="number" value={planForm.ram_mb} onChange={(event) => setPlanForm({ ...planForm, ram_mb: Number(event.target.value) })} className="mt-1" />
</div>
<div>
<Label>Disk (GB)</Label>
<Input type="number" value={planForm.disk_gb} onChange={(event) => setPlanForm({ ...planForm, disk_gb: Number(event.target.value) })} className="mt-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreatePlan(false)}>Cancel</Button>
<Button onClick={handleCreatePlan} disabled={creating || !planForm.name}>
{creating ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

1101
src/pages/ClientArea.jsx Normal file

File diff suppressed because it is too large Load Diff

544
src/pages/Dashboard.jsx Normal file
View File

@@ -0,0 +1,544 @@
import { useEffect, useState } from "react";
import { appClient } from "@/api/appClient";
import { Activity, CreditCard, Database, HardDrive, Server, Users } from "lucide-react";
import StatCard from "../components/shared/StatCard";
import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge";
import ResourceBar from "../components/shared/ResourceBar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
const lineColors = ["#1d4ed8", "#0f766e", "#ea580c", "#0f766e", "#b45309", "#4338ca"];
const clusterTimeframes = [
{ value: "hour", label: "1H" },
{ value: "day", label: "1D" },
{ value: "week", label: "1W" },
{ value: "month", label: "1M" },
{ value: "year", label: "1Y" },
];
const heatLevelStyles = {
critical: "border-rose-200 bg-rose-50",
warning: "border-amber-200 bg-amber-50",
elevated: "border-orange-200 bg-orange-50",
healthy: "border-emerald-200 bg-emerald-50",
};
const chartTooltipStyle = {
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "12px",
fontSize: "12px",
color: "hsl(var(--foreground))",
};
function formatDayLabel(raw) {
if (!raw) return "";
const parsed = new Date(`${raw}T00:00:00Z`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function formatTimescaleLabel(raw, timeframe) {
if (!raw) return "";
const date = new Date(raw);
if (timeframe === "hour") {
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}
if (timeframe === "year") {
return date.toLocaleDateString(undefined, { month: "short", year: "2-digit" });
}
return `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
}
export default function Dashboard() {
const [summary, setSummary] = useState(null);
const [nodes, setNodes] = useState([]);
const [networkAnalytics, setNetworkAnalytics] = useState(null);
const [clusterGraph, setClusterGraph] = useState(null);
const [clusterGraphTimeframe, setClusterGraphTimeframe] = useState("day");
const [clusterGraphLoading, setClusterGraphLoading] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
let active = true;
async function loadDashboard() {
try {
setLoading(true);
setError("");
const [summaryData, nodeData, networkData] = await Promise.all([
appClient.dashboard.summary(),
appClient.entities.ProxmoxNode.list(),
appClient.dashboard.networkUtilization({ days: 14, max_tenants: 5 }),
]);
if (!active) return;
setSummary(summaryData);
setNodes(nodeData);
setNetworkAnalytics(networkData);
} catch (loadError) {
if (!active) return;
setError(loadError?.message || "Failed to load dashboard data.");
} finally {
if (active) setLoading(false);
}
}
loadDashboard();
return () => {
active = false;
};
}, [refreshKey]);
useEffect(() => {
let active = true;
async function loadClusterGraph() {
try {
setClusterGraphLoading(true);
const payload = await appClient.proxmox.clusterUsageGraphs(clusterGraphTimeframe);
if (!active) return;
setClusterGraph(payload);
} catch {
if (!active) return;
setClusterGraph(null);
} finally {
if (active) setClusterGraphLoading(false);
}
}
loadClusterGraph();
return () => {
active = false;
};
}, [clusterGraphTimeframe, refreshKey]);
const metrics = summary?.metrics ?? {
vm_total: 0,
vm_running: 0,
node_total: 0,
tenant_total: 0,
revenue_paid_total: 0,
revenue_pending_total: 0,
};
const tenantSeries = networkAnalytics?.tenant_trends?.series ?? [];
const tenantTrendData = networkAnalytics?.tenant_trends?.chart_points ?? [];
const heatmapSummary = networkAnalytics?.subnet_heatmap?.summary ?? {
total_subnets: 0,
critical: 0,
warning: 0,
elevated: 0,
healthy: 0,
};
const heatmapCells = networkAnalytics?.subnet_heatmap?.cells ?? [];
const recentVms = summary?.recent_vms ?? [];
const onlineNodes = nodes.filter((node) => String(node.status ?? "").toLowerCase() === "online").length;
const tenantNameLookup = new Map(tenantSeries.map((seriesItem) => [seriesItem.tenant_id, seriesItem.tenant_name]));
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
if (error) {
return (
<div className="space-y-4">
<PageHeader title="Dashboard" description="Proxmox Virtual Environment Overview" />
<div className="surface-card p-5">
<p className="text-sm text-rose-600">{error}</p>
<button
type="button"
onClick={() => setRefreshKey((value) => value + 1)}
className="mt-3 inline-flex h-9 items-center justify-center rounded-lg border border-border px-3 text-xs text-foreground hover:bg-muted transition-colors"
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Dashboard" description="Proxmox Virtual Environment Overview" />
<div className="flex flex-wrap items-center gap-2">
<span className="kpi-chip">Region: Primary</span>
<span className="kpi-chip">Node Health: {onlineNodes}/{metrics.node_total || 0} online</span>
<span className="kpi-chip">Tracked Tenants: {tenantSeries.length}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={Server} label="Virtual Machines" value={metrics.vm_total} subtitle={`${metrics.vm_running} running`} trend={`${metrics.vm_running} active`} />
<StatCard icon={HardDrive} label="Nodes" value={metrics.node_total} subtitle={`${onlineNodes} online`} trend={`${metrics.node_total - onlineNodes} offline`} />
<StatCard icon={Users} label="Tenants" value={metrics.tenant_total} subtitle="Total onboarded tenants" trend={`${tenantSeries.length} tracked in IP trends`} />
<StatCard
icon={CreditCard}
label="Revenue (Paid)"
value={`NGN ${Number(metrics.revenue_paid_total ?? 0).toLocaleString()}`}
subtitle={`Pending: NGN ${Number(metrics.revenue_pending_total ?? 0).toLocaleString()}`}
trend="7-day billing window"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">Tenant IP Utilization Trends (14d)</h3>
<p className="panel-subtitle">Tenant-level subnet pressure and assignment trajectories.</p>
</div>
<Link to="/network" className="text-xs text-primary hover:underline">Open IPAM</Link>
</div>
{tenantTrendData.length === 0 ? (
<div className="flex items-center justify-center h-[220px] text-sm text-muted-foreground">
No tenant utilization data yet.
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={tenantTrendData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
tickFormatter={formatDayLabel}
axisLine={false}
tickLine={false}
/>
<YAxis tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={chartTooltipStyle}
labelFormatter={formatDayLabel}
formatter={(value, key) => [`${value} IPs`, tenantNameLookup.get(String(key)) ?? String(key)]}
/>
<Legend />
{tenantSeries.map((seriesItem, index) => (
<Line
key={seriesItem.tenant_id}
dataKey={seriesItem.tenant_id}
name={seriesItem.tenant_name}
type="monotone"
stroke={lineColors[index % lineColors.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
</ResponsiveContainer>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">Node Health</h3>
<p className="panel-subtitle">Live compute and memory utilization by node.</p>
</div>
</div>
{nodes.length === 0 ? (
<div className="flex items-center justify-center h-[220px] text-sm text-muted-foreground">No nodes configured</div>
) : (
<div className="space-y-4">
{nodes.slice(0, 4).map((node) => (
<div key={node.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm font-medium">{node.name}</span>
</div>
<StatusBadge status={String(node.status ?? "").toLowerCase()} />
</div>
<ResourceBar label="CPU" percentage={node.cpu_usage || 0} />
<ResourceBar label="RAM" used={node.ram_used_mb || 0} total={node.ram_total_mb || 1} unit=" MB" />
</div>
))}
</div>
)}
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.17 }}
className="chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">Cluster MRTG-Style Timescale Views</h3>
<p className="panel-subtitle">Capacity trend fidelity across compute, disk, and network.</p>
</div>
<Select value={clusterGraphTimeframe} onValueChange={setClusterGraphTimeframe}>
<SelectTrigger className="h-8 w-full sm:w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{clusterTimeframes.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{clusterGraphLoading ? (
<div className="flex items-center justify-center h-[260px]">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
) : clusterGraph?.points?.length > 0 ? (
<div className="space-y-4">
<div className="text-xs text-muted-foreground">
Source: {clusterGraph?.source ?? "n/a"} | Reporting nodes: {clusterGraph?.node_count ?? 0}
</div>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={clusterGraph.points}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
tickFormatter={(value) => formatTimescaleLabel(value, clusterGraphTimeframe)}
axisLine={false}
tickLine={false}
/>
<YAxis domain={[0, 100]} tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip
labelFormatter={(value) => formatTimescaleLabel(value, clusterGraphTimeframe)}
formatter={(value) => `${Number(value).toFixed(2)}%`}
contentStyle={chartTooltipStyle}
/>
<Legend />
<Line type="monotone" dataKey="cpu_pct" stroke="#f97316" dot={false} strokeWidth={2} name="CPU %" />
<Line type="monotone" dataKey="ram_pct" stroke="#0ea5e9" dot={false} strokeWidth={2} name="RAM %" />
<Line type="monotone" dataKey="disk_usage_pct" stroke="#22c55e" dot={false} strokeWidth={2} name="Disk %" />
<Line type="monotone" dataKey="io_wait_pct" stroke="#f43f5e" dot={false} strokeWidth={2} name="I/O Wait %" />
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-52 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={clusterGraph.points}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
tickFormatter={(value) => formatTimescaleLabel(value, clusterGraphTimeframe)}
axisLine={false}
tickLine={false}
/>
<YAxis tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis yAxisId="nodes" orientation="right" tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
labelFormatter={(value) => formatTimescaleLabel(value, clusterGraphTimeframe)}
formatter={(value, key) => {
if (key === "reporting_nodes") return [`${value} nodes`, "Reporting Nodes"];
return [`${Number(value).toFixed(3)} MB/s`, key === "network_in_mbps" ? "Net In" : "Net Out"];
}}
contentStyle={chartTooltipStyle}
/>
<Legend />
<Line type="monotone" dataKey="network_in_mbps" stroke="#4338ca" dot={false} strokeWidth={2} name="Net In MB/s" />
<Line type="monotone" dataKey="network_out_mbps" stroke="#0f766e" dot={false} strokeWidth={2} name="Net Out MB/s" />
<Line type="monotone" dataKey="reporting_nodes" yAxisId="nodes" stroke="#94a3b8" dot={false} strokeWidth={1.5} strokeDasharray="5 4" name="Reporting Nodes" />
</LineChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 md:grid-cols-6 gap-2 text-xs">
<div className="rounded-lg border border-border p-2">Avg CPU: {clusterGraph?.summary?.avg_cpu_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Peak CPU: {clusterGraph?.summary?.peak_cpu_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Avg I/O Wait: {clusterGraph?.summary?.avg_io_wait_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Peak Net In: {clusterGraph?.summary?.peak_network_in_mbps ?? 0} MB/s</div>
<div className="rounded-lg border border-border p-2">Peak Net Out: {clusterGraph?.summary?.peak_network_out_mbps ?? 0} MB/s</div>
<div className="rounded-lg border border-border p-2">Peak Nodes: {clusterGraph?.summary?.peak_reporting_nodes ?? 0}</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-10 text-center">
Cluster timescale metrics are not available yet.
</p>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18 }}
className="chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">Subnet Heatmap Widgets</h3>
<p className="panel-subtitle">Spot saturation hotspots and reserve pressure instantly.</p>
</div>
<Link to="/network" className="text-xs text-primary hover:underline">Manage pools</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-4">
<div className="rounded-lg border border-border p-2">
<p className="text-[11px] text-muted-foreground uppercase">Subnets</p>
<p className="text-lg font-semibold text-foreground">{heatmapSummary.total_subnets}</p>
</div>
<div className="rounded-lg border border-rose-200 bg-rose-50 p-2">
<p className="text-[11px] text-rose-600 uppercase">Critical</p>
<p className="text-lg font-semibold text-rose-600">{heatmapSummary.critical}</p>
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-2">
<p className="text-[11px] text-amber-600 uppercase">Warning</p>
<p className="text-lg font-semibold text-amber-600">{heatmapSummary.warning}</p>
</div>
<div className="rounded-lg border border-orange-200 bg-orange-50 p-2">
<p className="text-[11px] text-orange-600 uppercase">Elevated</p>
<p className="text-lg font-semibold text-orange-600">{heatmapSummary.elevated}</p>
</div>
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-2">
<p className="text-[11px] text-emerald-600 uppercase">Healthy</p>
<p className="text-lg font-semibold text-emerald-600">{heatmapSummary.healthy}</p>
</div>
</div>
{heatmapCells.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No subnet utilization data yet</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{heatmapCells.map((cell) => (
<div key={`${cell.subnet}-${cell.rank}`} className={`rounded-lg border p-3 ${heatLevelStyles[cell.heat_level] || heatLevelStyles.healthy}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold text-foreground font-mono">{cell.subnet}</p>
<p className="text-[11px] text-muted-foreground">
{cell.scope} / {cell.version}
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">#{cell.rank}</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<p className="text-muted-foreground">Pressure</p>
<p className="text-foreground font-medium">{cell.pressure_pct}%</p>
</div>
<div>
<p className="text-muted-foreground">Utilization</p>
<p className="text-foreground font-medium">{cell.utilization_pct}%</p>
</div>
<div>
<p className="text-muted-foreground">Assigned</p>
<p className="text-foreground font-medium">{cell.assigned}/{cell.total}</p>
</div>
<div>
<p className="text-muted-foreground">Reserved</p>
<p className="text-foreground font-medium">{cell.reserved}</p>
</div>
</div>
</div>
))}
</div>
)}
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="lg:col-span-2 chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">Recent Virtual Machines</h3>
<p className="panel-subtitle">Recently provisioned or active workload endpoints.</p>
</div>
<Link to="/vms" className="text-xs text-primary hover:underline">View all</Link>
</div>
{recentVms.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No virtual machines yet</p>
) : (
<div className="data-grid-wrap">
<table className="data-grid-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th className="hidden sm:table-cell">Node</th>
<th className="text-right">Usage</th>
</tr>
</thead>
<tbody>
{recentVms.slice(0, 6).map((vm) => (
<tr key={vm.id}>
<td>
<Link to={`/vms?id=${vm.id}`} className="text-sm font-medium text-foreground hover:text-primary transition-colors">{vm.name}</Link>
<p className="text-[11px] text-muted-foreground font-mono">{vm.ip_address || "No IP"}</p>
</td>
<td><StatusBadge status={String(vm.status ?? "").toLowerCase()} /></td>
<td className="hidden sm:table-cell text-sm text-muted-foreground">{vm.node}</td>
<td className="text-right text-xs text-muted-foreground font-mono">
{Math.round(Number(vm.cpu_usage ?? 0))}% CPU / {Math.round(Number(vm.ram_usage ?? 0))}% RAM / {Math.round(Number(vm.disk_usage ?? 0))}% Disk
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="chart-panel"
>
<div className="panel-head">
<div>
<h3 className="panel-title">Quick Actions</h3>
<p className="panel-subtitle">Common tasks for day-to-day operations.</p>
</div>
</div>
<div className="space-y-2">
{[
{ label: "Create VM", icon: Server, path: "/vms", color: "text-sky-600" },
{ label: "Add Node", icon: HardDrive, path: "/nodes", color: "text-indigo-600" },
{ label: "New Tenant", icon: Users, path: "/tenants", color: "text-emerald-600" },
{ label: "Run Backup", icon: Database, path: "/backups", color: "text-amber-600" },
{ label: "View Logs", icon: Activity, path: "/audit-logs", color: "text-blue-600" },
].map((action) => (
<Link
key={action.path}
to={action.path}
className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-muted transition-colors group"
>
<action.icon className={`w-4 h-4 ${action.color}`} />
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">{action.label}</span>
</Link>
))}
</div>
</motion.div>
</div>
</div>
);
}

117
src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,117 @@
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Lock, ShieldCheck, Workflow } from "lucide-react";
import { appClient } from "@/api/appClient";
import { useAuth } from "@/lib/AuthContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
function safeRedirect(rawNext) {
if (!rawNext || typeof rawNext !== "string") return "/";
if (!rawNext.startsWith("/")) return "/";
if (rawNext.startsWith("//")) return "/";
return rawNext;
}
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const { checkAppState } = useAuth();
const { toast } = useToast();
const params = useMemo(() => new URLSearchParams(location.search), [location.search]);
const redirectTo = safeRedirect(params.get("next"));
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
if (!email || !password) return;
try {
setSubmitting(true);
await appClient.auth.login(email.trim(), password);
await checkAppState();
navigate(redirectTo, { replace: true });
} catch (error) {
toast({
title: "Login failed",
description: error?.message || "Invalid credentials",
variant: "destructive"
});
} finally {
setSubmitting(false);
}
};
return (
<div className="app-shell-bg min-h-screen px-4 py-8 md:px-8">
<div className="mx-auto grid min-h-[calc(100vh-4rem)] w-full max-w-6xl overflow-hidden rounded-3xl border border-border bg-card shadow-[0_18px_60px_rgba(15,23,42,0.12)] md:grid-cols-[1.1fr_0.9fr]">
<section className="hidden bg-gradient-to-br from-blue-700 to-sky-700 p-10 text-white md:flex md:flex-col md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-blue-100">ProxPanel Cloud</p>
<h1 className="mt-4 text-4xl font-semibold leading-tight">Operate enterprise infrastructure with precision.</h1>
<p className="mt-4 max-w-md text-sm text-blue-100/90">
Unified control for compute, network, billing, and tenant operations, built for high-trust production environments.
</p>
</div>
<div className="space-y-4">
{[
{ icon: ShieldCheck, label: "Role-based access and policy enforcement" },
{ icon: Workflow, label: "Operational queues with observability and retries" },
{ icon: Lock, label: "End-to-end auditability and secure tenant isolation" }
].map((item) => (
<div key={item.label} className="flex items-center gap-3 rounded-xl bg-white/10 px-4 py-3 backdrop-blur-sm">
<item.icon className="h-4 w-4" />
<p className="text-sm">{item.label}</p>
</div>
))}
</div>
</section>
<section className="flex items-center justify-center p-6 sm:p-10">
<div className="w-full max-w-md">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">Secure Sign In</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight">Welcome back</h2>
<p className="mt-1 text-sm text-muted-foreground">Authenticate to access your enterprise control plane.</p>
<form className="mt-7 space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="admin@company.com"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Enter your password"
required
/>
</div>
<Button type="submit" className="mt-2 w-full" disabled={submitting}>
{submitting ? "Signing In..." : "Sign In"}
</Button>
</form>
</div>
</section>
</div>
</div>
);
}

681
src/pages/Monitoring.jsx Normal file
View File

@@ -0,0 +1,681 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
Bell,
CheckCircle2,
Cpu,
HardDrive,
MemoryStick,
Plus,
RefreshCw,
Server,
ShieldAlert,
TrendingDown,
TriangleAlert,
Wifi,
Wrench
} from "lucide-react";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis
} from "recharts";
import { appClient } from "@/api/appClient";
import PageHeader from "../components/shared/PageHeader";
import StatCard from "../components/shared/StatCard";
import StatusBadge from "../components/shared/StatusBadge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
const HEALTH_TARGETS = ["VM", "NODE", "CLUSTER"];
const HEALTH_TYPES = ["RESOURCE_THRESHOLD", "CONNECTIVITY", "SERVICE_PORT"];
const ALERT_SEVERITIES = ["WARNING", "ERROR", "CRITICAL"];
const ALERT_CHANNELS = ["IN_APP", "EMAIL", "WEBHOOK"];
const lineColors = {
pass: "#34d399",
warning: "#f59e0b",
fail: "#ef4444",
critical: "#ef4444",
error: "#f97316",
info: "#60a5fa"
};
function dateShort(value) {
if (!value) return "";
return new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function valueOrDash(value) {
if (value === null || value === undefined || value === "") return "-";
return value;
}
export default function Monitoring() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [overview, setOverview] = useState(null);
const [healthChecks, setHealthChecks] = useState([]);
const [alertRules, setAlertRules] = useState([]);
const [alertEvents, setAlertEvents] = useState([]);
const [notifications, setNotifications] = useState([]);
const [vms, setVms] = useState([]);
const [nodes, setNodes] = useState([]);
const [selectedCheckId, setSelectedCheckId] = useState("");
const [selectedCheckResults, setSelectedCheckResults] = useState([]);
const [healthForm, setHealthForm] = useState({
name: "",
target_type: "VM",
check_type: "RESOURCE_THRESHOLD",
vm_id: "",
node_id: "",
cpu_warn_pct: "75",
cpu_critical_pct: "90",
ram_warn_pct: "80",
ram_critical_pct: "92",
disk_warn_pct: "80",
disk_critical_pct: "95",
disk_io_read_warn: "60",
disk_io_read_critical: "120",
disk_io_write_warn: "60",
disk_io_write_critical: "120",
schedule_minutes: "5"
});
const [ruleForm, setRuleForm] = useState({
name: "",
vm_id: "",
node_id: "",
cpu_threshold_pct: "90",
ram_threshold_pct: "92",
disk_threshold_pct: "95",
disk_io_read_threshold: "120",
disk_io_write_threshold: "120",
severity: "WARNING",
channel: "IN_APP",
consecutive_breaches: "1"
});
useEffect(() => {
let active = true;
async function loadAll() {
try {
setLoading(true);
const [overviewData, checks, rules, events, notices, vmData, nodeData] = await Promise.all([
appClient.monitoring.overview(),
appClient.monitoring.listHealthChecks(),
appClient.monitoring.listAlertRules(),
appClient.monitoring.listAlertEvents({ limit: 100 }),
appClient.monitoring.listAlertNotifications({ limit: 100 }),
appClient.entities.VirtualMachine.list("-created_at", 300),
appClient.entities.ProxmoxNode.list("-created_at", 200)
]);
if (!active) return;
setOverview(overviewData);
setHealthChecks(checks?.data || []);
setAlertRules(rules?.data || []);
setAlertEvents(events?.data || []);
setNotifications(notices?.data || []);
setVms(vmData || []);
setNodes(nodeData || []);
setSelectedCheckId((current) => current || (checks?.data || [])[0]?.id || "");
} catch (error) {
if (!active) return;
toast({
title: "Monitoring Load Failed",
description: error?.message || "Unable to fetch monitoring data",
variant: "destructive"
});
} finally {
if (active) setLoading(false);
}
}
loadAll();
return () => {
active = false;
};
}, [refreshKey, toast]);
useEffect(() => {
let active = true;
async function loadResults() {
if (!selectedCheckId) {
setSelectedCheckResults([]);
return;
}
try {
const result = await appClient.monitoring.listHealthCheckResults(selectedCheckId, 50);
if (!active) return;
setSelectedCheckResults(result?.data || []);
} catch {
if (!active) return;
setSelectedCheckResults([]);
}
}
loadResults();
return () => {
active = false;
};
}, [selectedCheckId]);
const summary = overview?.summary || {
health_checks_total: 0,
health_checks_enabled: 0,
health_failures_24h: 0,
active_alerts: 0,
notifications_sent_24h: 0,
resource_exhaustion_floor_days: null
};
const forecast = overview?.cluster_forecast || {
pressure: {},
days_to_exhaustion: {},
node_breakdown: []
};
const healthTrend = overview?.health_trend_7d || [];
const alertTrend = overview?.alert_trend_7d || [];
const faulty = overview?.faulty_deployments || {
failed_task_count: 0,
failure_rate_pct: 0,
stale_queued_tasks: 0,
top_error_messages: []
};
const activeAlertCount = useMemo(() => {
return alertEvents.filter((event) => String(event.status || "").toUpperCase() === "OPEN").length;
}, [alertEvents]);
async function reloadAll() {
setRefreshKey((value) => value + 1);
}
async function handleCreateHealthCheck() {
try {
const payload = {
name: healthForm.name,
target_type: healthForm.target_type,
check_type: healthForm.check_type,
vm_id: healthForm.target_type === "VM" && healthForm.vm_id ? healthForm.vm_id : undefined,
node_id: healthForm.target_type === "NODE" && healthForm.node_id ? healthForm.node_id : undefined,
cpu_warn_pct: Number(healthForm.cpu_warn_pct),
cpu_critical_pct: Number(healthForm.cpu_critical_pct),
ram_warn_pct: Number(healthForm.ram_warn_pct),
ram_critical_pct: Number(healthForm.ram_critical_pct),
disk_warn_pct: Number(healthForm.disk_warn_pct),
disk_critical_pct: Number(healthForm.disk_critical_pct),
disk_io_read_warn: Number(healthForm.disk_io_read_warn),
disk_io_read_critical: Number(healthForm.disk_io_read_critical),
disk_io_write_warn: Number(healthForm.disk_io_write_warn),
disk_io_write_critical: Number(healthForm.disk_io_write_critical),
schedule_minutes: Number(healthForm.schedule_minutes)
};
await appClient.monitoring.createHealthCheck(payload);
toast({ title: "Health Check Created" });
setHealthForm((prev) => ({ ...prev, name: "" }));
await reloadAll();
} catch (error) {
toast({
title: "Create Health Check Failed",
description: error?.message || "Request failed",
variant: "destructive"
});
}
}
async function handleCreateAlertRule() {
try {
const payload = {
name: ruleForm.name,
vm_id: ruleForm.vm_id || undefined,
node_id: ruleForm.node_id || undefined,
cpu_threshold_pct: Number(ruleForm.cpu_threshold_pct),
ram_threshold_pct: Number(ruleForm.ram_threshold_pct),
disk_threshold_pct: Number(ruleForm.disk_threshold_pct),
disk_io_read_threshold: Number(ruleForm.disk_io_read_threshold),
disk_io_write_threshold: Number(ruleForm.disk_io_write_threshold),
severity: ruleForm.severity,
channels: [ruleForm.channel],
consecutive_breaches: Number(ruleForm.consecutive_breaches)
};
await appClient.monitoring.createAlertRule(payload);
toast({ title: "Alert Rule Created" });
setRuleForm((prev) => ({ ...prev, name: "" }));
await reloadAll();
} catch (error) {
toast({
title: "Create Alert Rule Failed",
description: error?.message || "Request failed",
variant: "destructive"
});
}
}
async function handleRunCheck(checkId) {
try {
await appClient.monitoring.runHealthCheck(checkId);
toast({ title: "Health Check Executed" });
setSelectedCheckId(checkId);
await reloadAll();
} catch (error) {
toast({
title: "Run Failed",
description: error?.message || "Unable to execute check",
variant: "destructive"
});
}
}
async function handleEvaluateAlerts() {
try {
const result = await appClient.monitoring.evaluateAlerts();
toast({
title: "Alert Evaluation Complete",
description: `Evaluated ${result.evaluated}, triggered ${result.triggered}, resolved ${result.resolved}`
});
await reloadAll();
} catch (error) {
toast({
title: "Alert Evaluation Failed",
description: error?.message || "Unable to evaluate",
variant: "destructive"
});
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Monitoring" description="Health checks, threshold alerts, failure analytics, and resource forecasting">
<div className="flex gap-2">
<Button variant="outline" className="gap-2" onClick={reloadAll}><RefreshCw className="w-4 h-4" /> Refresh</Button>
<Button className="gap-2" onClick={handleEvaluateAlerts}><ShieldAlert className="w-4 h-4" /> Evaluate Alerts</Button>
</div>
</PageHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={Server} label="Health Checks" value={summary.health_checks_total} subtitle={`${summary.health_checks_enabled} enabled`} />
<StatCard icon={TriangleAlert} label="Failures (24h)" value={summary.health_failures_24h} subtitle="Health-check failures" />
<StatCard icon={AlertTriangle} label="Active Alerts" value={activeAlertCount} subtitle={`${summary.active_alerts} open events`} />
<StatCard
icon={Bell}
label="Notifications (24h)"
value={summary.notifications_sent_24h}
subtitle={`Exhaustion floor: ${valueOrDash(summary.resource_exhaustion_floor_days)}`}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="surface-card p-5">
<h3 className="text-sm font-semibold text-foreground mb-3">Health Result Trend (7d)</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={healthTrend}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis dataKey="day" tickFormatter={dateShort} tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip labelFormatter={dateShort} />
<Legend />
<Line type="monotone" dataKey="pass" stroke={lineColors.pass} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="warning" stroke={lineColors.warning} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="fail" stroke={lineColors.fail} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="surface-card p-5">
<h3 className="text-sm font-semibold text-foreground mb-3">Alert Trend (7d)</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={alertTrend}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis dataKey="day" tickFormatter={dateShort} tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip labelFormatter={dateShort} />
<Legend />
<Line type="monotone" dataKey="critical" stroke={lineColors.critical} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="error" stroke={lineColors.error} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="warning" stroke={lineColors.warning} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="info" stroke={lineColors.info} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="surface-card p-5 space-y-3">
<h3 className="text-sm font-semibold text-foreground">Faulty Deployment Insights</h3>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-border p-3">
<p className="text-[11px] uppercase text-muted-foreground">Failed Tasks</p>
<p className="text-xl font-semibold text-rose-600">{faulty.failed_task_count}</p>
</div>
<div className="rounded-lg border border-border p-3">
<p className="text-[11px] uppercase text-muted-foreground">Failure Rate</p>
<p className="text-xl font-semibold text-amber-600">{faulty.failure_rate_pct}%</p>
</div>
<div className="rounded-lg border border-border p-3 col-span-2">
<p className="text-[11px] uppercase text-muted-foreground">Stale Queued Tasks</p>
<p className="text-lg font-semibold text-orange-600">{faulty.stale_queued_tasks}</p>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-2">Top Error Signatures</p>
<div className="space-y-1.5 max-h-40 overflow-auto pr-1">
{(faulty.top_error_messages || []).map((item, index) => (
<div key={`${item.message}-${index}`} className="text-xs rounded-md border border-border p-2">
<p className="text-foreground truncate">{item.message}</p>
<p className="text-muted-foreground">{item.count} occurrences</p>
</div>
))}
{(faulty.top_error_messages || []).length === 0 && (
<p className="text-xs text-muted-foreground">No failed task signatures in the selected window.</p>
)}
</div>
</div>
</div>
<div className="surface-card p-5 space-y-3">
<h3 className="text-sm font-semibold text-foreground">Cluster Remaining-Resource Forecast</h3>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border border-border p-2">
<Cpu className="w-4 h-4 mx-auto text-sky-600" />
<p className="text-[11px] text-muted-foreground mt-1">CPU Days</p>
<p className="text-sm font-semibold">{valueOrDash(forecast.days_to_exhaustion?.cpu_cores)}</p>
</div>
<div className="rounded-lg border border-border p-2">
<MemoryStick className="w-4 h-4 mx-auto text-blue-600" />
<p className="text-[11px] text-muted-foreground mt-1">RAM Days</p>
<p className="text-sm font-semibold">{valueOrDash(forecast.days_to_exhaustion?.ram_mb)}</p>
</div>
<div className="rounded-lg border border-border p-2">
<HardDrive className="w-4 h-4 mx-auto text-emerald-400" />
<p className="text-[11px] text-muted-foreground mt-1">Disk Days</p>
<p className="text-sm font-semibold">{valueOrDash(forecast.days_to_exhaustion?.disk_gb)}</p>
</div>
</div>
<div className="space-y-1.5 max-h-44 overflow-auto pr-1">
{(forecast.node_breakdown || []).map((node) => (
<div key={node.node_id} className="rounded-lg border border-border p-2">
<p className="text-xs font-medium text-foreground">{node.node_name}</p>
<p className="text-[11px] text-muted-foreground">CPU {node.cpu_pressure_pct}% | RAM {node.ram_pressure_pct}% | Disk {node.disk_pressure_pct}%</p>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="xl:col-span-2 surface-card p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Server Health Check Definitions</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="text-left py-2 pr-2">Name</th>
<th className="text-left py-2 pr-2">Target</th>
<th className="text-left py-2 pr-2">Status</th>
<th className="text-left py-2 pr-2">Last Run</th>
<th className="text-right py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{healthChecks.map((check) => {
const latest = check.results?.[0];
return (
<tr key={check.id} className="hover:bg-muted/30">
<td className="py-2 pr-2 text-sm">
<button type="button" className="text-left hover:text-primary" onClick={() => setSelectedCheckId(check.id)}>{check.name}</button>
<p className="text-[11px] text-muted-foreground">{check.check_type}</p>
</td>
<td className="py-2 pr-2 text-xs text-muted-foreground">
{check.target_type} {check.vm?.name || check.node?.name || "Cluster"}
</td>
<td className="py-2 pr-2">
<StatusBadge status={String(latest?.status || "unknown").toLowerCase()} />
</td>
<td className="py-2 pr-2 text-xs text-muted-foreground">{latest?.checked_at ? new Date(latest.checked_at).toLocaleString() : "Never"}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => handleRunCheck(check.id)} className="h-8">Run</Button>
</td>
</tr>
);
})}
{healthChecks.length === 0 && (
<tr><td colSpan={5} className="py-6 text-center text-sm text-muted-foreground">No health checks configured.</td></tr>
)}
</tbody>
</table>
</div>
<div>
<p className="text-xs text-muted-foreground mb-2">Result Log ({selectedCheckResults.length})</p>
<div className="space-y-1.5 max-h-40 overflow-auto pr-1">
{selectedCheckResults.map((result) => (
<div key={result.id} className="rounded-lg border border-border p-2">
<div className="flex items-center justify-between">
<StatusBadge status={String(result.status || "unknown").toLowerCase()} />
<span className="text-[11px] text-muted-foreground">{new Date(result.checked_at).toLocaleString()}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">{result.message || "No message"}</p>
</div>
))}
{selectedCheckResults.length === 0 && (
<p className="text-xs text-muted-foreground">Select and run a health check to populate logs.</p>
)}
</div>
</div>
</div>
<div className="surface-card p-5 space-y-4">
<h3 className="text-sm font-semibold text-foreground">Create Health Check</h3>
<div className="space-y-3">
<div>
<Label>Name</Label>
<Input value={healthForm.name} onChange={(event) => setHealthForm((prev) => ({ ...prev, name: event.target.value }))} className="mt-1" />
</div>
<div>
<Label>Target</Label>
<Select value={healthForm.target_type} onValueChange={(value) => setHealthForm((prev) => ({ ...prev, target_type: value }))}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{HEALTH_TARGETS.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Check Type</Label>
<Select value={healthForm.check_type} onValueChange={(value) => setHealthForm((prev) => ({ ...prev, check_type: value }))}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{HEALTH_TYPES.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</div>
{healthForm.target_type === "VM" && (
<div>
<Label>VM</Label>
<Select value={healthForm.vm_id} onValueChange={(value) => setHealthForm((prev) => ({ ...prev, vm_id: value }))}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Select VM" /></SelectTrigger>
<SelectContent>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent>
</Select>
</div>
)}
{healthForm.target_type === "NODE" && (
<div>
<Label>Node</Label>
<Select value={healthForm.node_id} onValueChange={(value) => setHealthForm((prev) => ({ ...prev, node_id: value }))}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Select Node" /></SelectTrigger>
<SelectContent>{nodes.map((node) => <SelectItem key={node.id} value={node.id}>{node.name}</SelectItem>)}</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<Input value={healthForm.cpu_warn_pct} onChange={(event) => setHealthForm((prev) => ({ ...prev, cpu_warn_pct: event.target.value }))} placeholder="CPU Warn" />
<Input value={healthForm.cpu_critical_pct} onChange={(event) => setHealthForm((prev) => ({ ...prev, cpu_critical_pct: event.target.value }))} placeholder="CPU Crit" />
</div>
<div className="grid grid-cols-2 gap-2">
<Input value={healthForm.ram_warn_pct} onChange={(event) => setHealthForm((prev) => ({ ...prev, ram_warn_pct: event.target.value }))} placeholder="RAM Warn" />
<Input value={healthForm.ram_critical_pct} onChange={(event) => setHealthForm((prev) => ({ ...prev, ram_critical_pct: event.target.value }))} placeholder="RAM Crit" />
</div>
<div className="grid grid-cols-2 gap-2">
<Input value={healthForm.disk_warn_pct} onChange={(event) => setHealthForm((prev) => ({ ...prev, disk_warn_pct: event.target.value }))} placeholder="Disk Warn" />
<Input value={healthForm.disk_critical_pct} onChange={(event) => setHealthForm((prev) => ({ ...prev, disk_critical_pct: event.target.value }))} placeholder="Disk Crit" />
</div>
<div className="grid grid-cols-2 gap-2">
<Input value={healthForm.disk_io_read_warn} onChange={(event) => setHealthForm((prev) => ({ ...prev, disk_io_read_warn: event.target.value }))} placeholder="Disk Read Warn (MB/s)" />
<Input value={healthForm.disk_io_read_critical} onChange={(event) => setHealthForm((prev) => ({ ...prev, disk_io_read_critical: event.target.value }))} placeholder="Disk Read Crit (MB/s)" />
</div>
<div className="grid grid-cols-2 gap-2">
<Input value={healthForm.disk_io_write_warn} onChange={(event) => setHealthForm((prev) => ({ ...prev, disk_io_write_warn: event.target.value }))} placeholder="Disk Write Warn (MB/s)" />
<Input value={healthForm.disk_io_write_critical} onChange={(event) => setHealthForm((prev) => ({ ...prev, disk_io_write_critical: event.target.value }))} placeholder="Disk Write Crit (MB/s)" />
</div>
<Input value={healthForm.schedule_minutes} onChange={(event) => setHealthForm((prev) => ({ ...prev, schedule_minutes: event.target.value }))} placeholder="Schedule (minutes)" />
<Button className="w-full gap-2" onClick={handleCreateHealthCheck}><Plus className="w-4 h-4" /> Create</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="xl:col-span-2 surface-card p-5 space-y-3">
<h3 className="text-sm font-semibold text-foreground">Threshold Alert Rules & Active Events</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{alertRules.map((rule) => (
<div key={rule.id} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-foreground">{rule.name}</p>
<p className="text-[11px] text-muted-foreground">{rule.vm?.name || rule.node?.name || "Cluster/Tenant scope"}</p>
</div>
<StatusBadge status={rule.enabled ? "active" : "stopped"} />
</div>
<p className="text-[11px] text-muted-foreground mt-2">
CPU {valueOrDash(rule.cpu_threshold_pct)} | RAM {valueOrDash(rule.ram_threshold_pct)} | Disk {valueOrDash(rule.disk_threshold_pct)}
</p>
<p className="text-[11px] text-muted-foreground">
Disk Read {valueOrDash(rule.disk_io_read_threshold)} MB/s | Disk Write {valueOrDash(rule.disk_io_write_threshold)} MB/s
</p>
</div>
))}
{alertRules.length === 0 && <p className="text-sm text-muted-foreground">No alert rules configured.</p>}
</div>
<div>
<p className="text-xs text-muted-foreground mb-2">Recent Alert Events</p>
<div className="space-y-2 max-h-48 overflow-auto pr-1">
{alertEvents.map((event) => (
<div key={event.id} className="rounded-lg border border-border p-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-foreground">{event.title}</p>
<StatusBadge status={String(event.status || "unknown").toLowerCase()} />
</div>
<p className="text-[11px] text-muted-foreground">{event.message || "No details"}</p>
</div>
))}
{alertEvents.length === 0 && <p className="text-xs text-muted-foreground">No alert events yet.</p>}
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-2">Notification Delivery Log</p>
<div className="space-y-1.5 max-h-36 overflow-auto pr-1">
{notifications.map((item) => (
<div key={item.id} className="rounded-md border border-border p-2 text-xs">
<p className="text-foreground">{item.channel} - {item.status}</p>
<p className="text-muted-foreground">{item.destination || "In-app"}</p>
</div>
))}
{notifications.length === 0 && <p className="text-xs text-muted-foreground">No notifications logged yet.</p>}
</div>
</div>
</div>
<div className="surface-card p-5 space-y-4">
<h3 className="text-sm font-semibold text-foreground">Create Threshold Alert Rule</h3>
<div className="space-y-3">
<div>
<Label>Name</Label>
<Input value={ruleForm.name} onChange={(event) => setRuleForm((prev) => ({ ...prev, name: event.target.value }))} className="mt-1" />
</div>
<div>
<Label>VM Scope (optional)</Label>
<Select value={ruleForm.vm_id} onValueChange={(value) => setRuleForm((prev) => ({ ...prev, vm_id: value }))}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Select VM" /></SelectTrigger>
<SelectContent>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Severity</Label>
<Select value={ruleForm.severity} onValueChange={(value) => setRuleForm((prev) => ({ ...prev, severity: value }))}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{ALERT_SEVERITIES.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label>Channel</Label>
<Select value={ruleForm.channel} onValueChange={(value) => setRuleForm((prev) => ({ ...prev, channel: value }))}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{ALERT_CHANNELS.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-2">
<Input value={ruleForm.cpu_threshold_pct} onChange={(event) => setRuleForm((prev) => ({ ...prev, cpu_threshold_pct: event.target.value }))} placeholder="CPU %" />
<Input value={ruleForm.ram_threshold_pct} onChange={(event) => setRuleForm((prev) => ({ ...prev, ram_threshold_pct: event.target.value }))} placeholder="RAM %" />
<Input value={ruleForm.disk_threshold_pct} onChange={(event) => setRuleForm((prev) => ({ ...prev, disk_threshold_pct: event.target.value }))} placeholder="Disk %" />
</div>
<div className="grid grid-cols-2 gap-2">
<Input value={ruleForm.disk_io_read_threshold} onChange={(event) => setRuleForm((prev) => ({ ...prev, disk_io_read_threshold: event.target.value }))} placeholder="Disk Read MB/s" />
<Input value={ruleForm.disk_io_write_threshold} onChange={(event) => setRuleForm((prev) => ({ ...prev, disk_io_write_threshold: event.target.value }))} placeholder="Disk Write MB/s" />
</div>
<Input value={ruleForm.consecutive_breaches} onChange={(event) => setRuleForm((prev) => ({ ...prev, consecutive_breaches: event.target.value }))} placeholder="Consecutive breaches" />
<Button className="w-full gap-2" onClick={handleCreateAlertRule}><Wrench className="w-4 h-4" /> Create Rule</Button>
</div>
</div>
</div>
<div className="surface-card p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Wifi className="w-3.5 h-3.5" />
<span>{summary.overall_severity === "CRITICAL" ? "High operational risk detected" : "Monitoring posture stable"}</span>
<span className="mx-1">|</span>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{summary.health_checks_enabled} active checks</span>
<span className="mx-1">|</span>
<TrendingDown className="w-3.5 h-3.5" />
<span>Forecast floor: {valueOrDash(summary.resource_exhaustion_floor_days)} days</span>
</div>
</div>
</div>
);
}

249
src/pages/NetworkIpam.jsx Normal file
View File

@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { appClient } from "@/api/appClient";
import PageHeader from "@/components/shared/PageHeader";
import EmptyState from "@/components/shared/EmptyState";
import StatusBadge from "@/components/shared/StatusBadge";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const splitMultiValue = (input) =>
input
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
export default function NetworkIpam() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [ipData, setIpData] = useState([]);
const [assignments, setAssignments] = useState([]);
const [vms, setVms] = useState([]);
const [tenants, setTenants] = useState([]);
const [utilization, setUtilization] = useState({ subnets: [], assignment_summary: { total: 0, ipv4: 0, ipv6: 0 } });
const [quotas, setQuotas] = useState([]);
const [ranges, setRanges] = useState([]);
const [policies, setPolicies] = useState([]);
const [importForm, setImportForm] = useState({ addresses: "", cidr_blocks: "", scope: "PUBLIC" });
const [assignForm, setAssignForm] = useState({ vm_id: "", address: "", scope: "PUBLIC", version: "IPV4", assignment_type: "ADDITIONAL" });
const [quotaForm, setQuotaForm] = useState({ tenant_id: "", ipv4_limit: "", ipv6_limit: "" });
const [rangeForm, setRangeForm] = useState({ name: "", cidr: "", scope: "PUBLIC", tenant_id: "" });
const [policyForm, setPolicyForm] = useState({ name: "", tenant_id: "", allocation_strategy: "BEST_FIT", priority: "100" });
const activeAssignments = useMemo(() => assignments.filter((x) => x.is_active), [assignments]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [ips, asn, vmList, tenantList, dash, quotaList, rangeList, policyList] = await Promise.all([
appClient.network.listIpAddresses({ limit: 300 }),
appClient.network.listIpAssignments({ active_only: true }),
appClient.entities.VirtualMachine.list("-created_at", 200),
appClient.entities.Tenant.list("-created_at", 200),
appClient.network.subnetUtilization(),
appClient.network.listTenantQuotas(),
appClient.network.listReservedRanges(),
appClient.network.listPolicies()
]);
setIpData(ips?.data ?? []);
setAssignments(asn?.data ?? []);
setVms(vmList ?? []);
setTenants(tenantList ?? []);
setUtilization(dash ?? { subnets: [], assignment_summary: { total: 0, ipv4: 0, ipv6: 0 } });
setQuotas(quotaList?.data ?? []);
setRanges(rangeList?.data ?? []);
setPolicies(policyList?.data ?? []);
} catch (error) {
toast({ title: "Load Failed", description: error?.message ?? "Could not load network data", variant: "destructive" });
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
loadData();
}, [loadData]);
const runAction = async (fn, success) => {
try {
await fn();
toast({ title: success });
await loadData();
} catch (error) {
toast({ title: "Action Failed", description: error?.message ?? "Request failed", variant: "destructive" });
}
};
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
}
return (
<div className="space-y-6">
<PageHeader title="Network & IPAM" description="Phase 4 Step 2: utilization dashboard + quota/reserved/best-fit policy controls" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">Assigned</p><p className="text-2xl">{utilization.assignment_summary?.total ?? 0}</p></div>
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">IPv4</p><p className="text-2xl">{utilization.assignment_summary?.ipv4 ?? 0}</p></div>
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">IPv6</p><p className="text-2xl">{utilization.assignment_summary?.ipv6 ?? 0}</p></div>
<div className="surface-card p-4"><p className="text-xs text-muted-foreground">Subnets</p><p className="text-2xl">{utilization.subnets?.length ?? 0}</p></div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Import IPs</h2>
<textarea className="w-full min-h-[70px] rounded-md border bg-muted px-3 py-2 text-sm" placeholder="Addresses (comma/new line)" value={importForm.addresses} onChange={(e) => setImportForm((p) => ({ ...p, addresses: e.target.value }))} />
<textarea className="w-full min-h-[70px] rounded-md border bg-muted px-3 py-2 text-sm" placeholder="CIDR blocks (comma/new line)" value={importForm.cidr_blocks} onChange={(e) => setImportForm((p) => ({ ...p, cidr_blocks: e.target.value }))} />
<Select value={importForm.scope} onValueChange={(value) => setImportForm((p) => ({ ...p, scope: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PUBLIC">PUBLIC</SelectItem><SelectItem value="PRIVATE">PRIVATE</SelectItem></SelectContent>
</Select>
<Button onClick={() => runAction(() => appClient.network.importIpAddresses({ scope: importForm.scope, addresses: splitMultiValue(importForm.addresses), cidr_blocks: splitMultiValue(importForm.cidr_blocks) }), "IP import completed")}>Import</Button>
</div>
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Assign IP (Best-Fit Policy)</h2>
<Select value={assignForm.vm_id} onValueChange={(value) => setAssignForm((p) => ({ ...p, vm_id: value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Select VM" /></SelectTrigger>
<SelectContent>{vms.map((vm) => <SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>)}</SelectContent>
</Select>
<Input className="bg-muted" placeholder="Specific IP (optional)" value={assignForm.address} onChange={(e) => setAssignForm((p) => ({ ...p, address: e.target.value }))} />
<div className="grid grid-cols-3 gap-2">
<Select value={assignForm.scope} onValueChange={(value) => setAssignForm((p) => ({ ...p, scope: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PUBLIC">PUBLIC</SelectItem><SelectItem value="PRIVATE">PRIVATE</SelectItem></SelectContent>
</Select>
<Select value={assignForm.version} onValueChange={(value) => setAssignForm((p) => ({ ...p, version: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="IPV4">IPV4</SelectItem><SelectItem value="IPV6">IPV6</SelectItem></SelectContent>
</Select>
<Select value={assignForm.assignment_type} onValueChange={(value) => setAssignForm((p) => ({ ...p, assignment_type: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PRIMARY">PRIMARY</SelectItem><SelectItem value="ADDITIONAL">ADDITIONAL</SelectItem><SelectItem value="FLOATING">FLOATING</SelectItem></SelectContent>
</Select>
</div>
<Button disabled={!assignForm.vm_id} onClick={() => runAction(() => appClient.network.assignIpAddress(assignForm), "IP assigned")}>Assign</Button>
<div className="space-y-1 pt-2 border-t">
{activeAssignments.slice(0, 6).map((item) => (
<div key={item.id} className="flex items-center justify-between rounded-md bg-muted/40 px-2 py-1.5 text-xs">
<span className="font-mono">{item.ip_address?.address}/{item.ip_address?.cidr}</span>
<Button size="sm" variant="outline" onClick={() => runAction(() => appClient.network.returnIpAddress({ assignment_id: item.id }), "IP returned")}>Return</Button>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Tenant Quota</h2>
<Select value={quotaForm.tenant_id} onValueChange={(value) => setQuotaForm((p) => ({ ...p, tenant_id: value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Tenant" /></SelectTrigger>
<SelectContent>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-2">
<Input className="bg-muted" placeholder="IPv4 limit" value={quotaForm.ipv4_limit} onChange={(e) => setQuotaForm((p) => ({ ...p, ipv4_limit: e.target.value }))} />
<Input className="bg-muted" placeholder="IPv6 limit" value={quotaForm.ipv6_limit} onChange={(e) => setQuotaForm((p) => ({ ...p, ipv6_limit: e.target.value }))} />
</div>
<Button disabled={!quotaForm.tenant_id} onClick={() => runAction(() => appClient.network.upsertTenantQuota({ tenant_id: quotaForm.tenant_id, ipv4_limit: quotaForm.ipv4_limit ? Number(quotaForm.ipv4_limit) : null, ipv6_limit: quotaForm.ipv6_limit ? Number(quotaForm.ipv6_limit) : null }), "Quota saved")}>Save Quota</Button>
<p className="text-xs text-muted-foreground">Configured quotas: {quotas.length}</p>
</div>
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">Reserved Range</h2>
<Input className="bg-muted" placeholder="Name" value={rangeForm.name} onChange={(e) => setRangeForm((p) => ({ ...p, name: e.target.value }))} />
<Input className="bg-muted" placeholder="CIDR (10.0.10.0/24)" value={rangeForm.cidr} onChange={(e) => setRangeForm((p) => ({ ...p, cidr: e.target.value }))} />
<Select value={rangeForm.scope} onValueChange={(value) => setRangeForm((p) => ({ ...p, scope: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="PUBLIC">PUBLIC</SelectItem><SelectItem value="PRIVATE">PRIVATE</SelectItem></SelectContent>
</Select>
<Select value={rangeForm.tenant_id || "__GLOBAL__"} onValueChange={(value) => setRangeForm((p) => ({ ...p, tenant_id: value === "__GLOBAL__" ? "" : value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Tenant (optional)" /></SelectTrigger>
<SelectContent><SelectItem value="__GLOBAL__">Global</SelectItem>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
<Button onClick={() => runAction(() => appClient.network.createReservedRange({ ...rangeForm, tenant_id: rangeForm.tenant_id || undefined }), "Reserved range created")}>Create Range</Button>
<p className="text-xs text-muted-foreground">Reserved ranges: {ranges.length}</p>
</div>
<div className="surface-card p-4 space-y-2">
<h2 className="text-sm font-semibold">IP Pool Policy</h2>
<Input className="bg-muted" placeholder="Policy name" value={policyForm.name} onChange={(e) => setPolicyForm((p) => ({ ...p, name: e.target.value }))} />
<Select value={policyForm.tenant_id || "__GLOBAL__"} onValueChange={(value) => setPolicyForm((p) => ({ ...p, tenant_id: value === "__GLOBAL__" ? "" : value }))}>
<SelectTrigger className="bg-muted"><SelectValue placeholder="Tenant (optional)" /></SelectTrigger>
<SelectContent><SelectItem value="__GLOBAL__">Global</SelectItem>{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}</SelectContent>
</Select>
<Select value={policyForm.allocation_strategy} onValueChange={(value) => setPolicyForm((p) => ({ ...p, allocation_strategy: value }))}>
<SelectTrigger className="bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="BEST_FIT">BEST_FIT</SelectItem><SelectItem value="FIRST_AVAILABLE">FIRST_AVAILABLE</SelectItem></SelectContent>
</Select>
<Input className="bg-muted" placeholder="Priority" value={policyForm.priority} onChange={(e) => setPolicyForm((p) => ({ ...p, priority: e.target.value }))} />
<Button onClick={() => runAction(() => appClient.network.createPolicy({ name: policyForm.name, tenant_id: policyForm.tenant_id || undefined, allocation_strategy: policyForm.allocation_strategy, priority: Number(policyForm.priority || 100) }), "Policy created")}>Create Policy</Button>
<p className="text-xs text-muted-foreground">Policies: {policies.length}</p>
</div>
</div>
<div className="surface-card p-4">
<h2 className="text-sm font-semibold mb-3">Subnet Utilization</h2>
{utilization.subnets?.length === 0 ? (
<EmptyState title="No Subnets Yet" description="Import IP blocks to populate utilization." />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs uppercase text-muted-foreground">
<th className="text-left px-3 py-2">Subnet</th>
<th className="text-left px-3 py-2">Scope</th>
<th className="text-left px-3 py-2">Assigned</th>
<th className="text-left px-3 py-2">Available</th>
<th className="text-left px-3 py-2">Utilization</th>
</tr>
</thead>
<tbody className="divide-y">
{utilization.subnets.slice(0, 30).map((item) => (
<tr key={`${item.scope}-${item.subnet}`}>
<td className="px-3 py-2 font-mono text-xs">{item.subnet}</td>
<td className="px-3 py-2">{item.scope}</td>
<td className="px-3 py-2">{item.assigned}</td>
<td className="px-3 py-2">{item.available}</td>
<td className="px-3 py-2">{item.utilization_pct}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="surface-card p-4">
<h2 className="text-sm font-semibold mb-3">IP Pool</h2>
{ipData.length === 0 ? (
<EmptyState title="No IP Addresses In Pool" description="Import addresses or CIDR blocks to initialize IPAM." />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs uppercase text-muted-foreground">
<th className="text-left px-3 py-2">Address</th>
<th className="text-left px-3 py-2">Scope</th>
<th className="text-left px-3 py-2">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{ipData.slice(0, 100).map((item) => (
<tr key={item.id}>
<td className="px-3 py-2 font-mono text-xs">{item.address}/{item.cidr}</td>
<td className="px-3 py-2">{item.scope}</td>
<td className="px-3 py-2"><StatusBadge status={item.status?.toLowerCase()} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

258
src/pages/Nodes.jsx Normal file
View File

@@ -0,0 +1,258 @@
import { useState, useEffect } from "react";
import { appClient } from "@/api/appClient";
import { HardDrive, Plus, Wifi, WifiOff, LineChart as LineChartIcon, Trash2 } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState";
import ResourceBar from "../components/shared/ResourceBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import { motion } from "framer-motion";
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
const graphTimeframes = ["hour", "day", "week", "month", "year"];
function formatGraphLabel(value) {
if (!value) return "";
const date = new Date(value);
return `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
}
export default function Nodes() {
const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [showGraphs, setShowGraphs] = useState(false);
const [graphNode, setGraphNode] = useState(null);
const [graphTimeframe, setGraphTimeframe] = useState("day");
const [graphLoading, setGraphLoading] = useState(false);
const [graphData, setGraphData] = useState(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ name: "", hostname: "", cpu_cores: 8, ram_total_mb: 32768, disk_total_gb: 500, pve_version: "8.1" });
const { toast } = useToast();
useEffect(() => { loadData(); }, []);
const loadData = async () => {
const data = await appClient.entities.ProxmoxNode.list("-created_date");
setNodes(data);
setLoading(false);
};
const handleAdd = async () => {
if (!form.name || !form.hostname) return;
setSaving(true);
await appClient.entities.ProxmoxNode.create({ ...form, status: "online", cpu_usage: 0, ram_used_mb: 0, disk_used_gb: 0, vm_count: 0, uptime_seconds: 0 });
await loadData();
setShowAdd(false);
setForm({ name: "", hostname: "", cpu_cores: 8, ram_total_mb: 32768, disk_total_gb: 500, pve_version: "8.1" });
setSaving(false);
toast({ title: "Node Added", description: `${form.name} has been added.` });
};
const handleDelete = async (node) => {
await appClient.entities.ProxmoxNode.delete(node.id);
await loadData();
toast({ title: "Node Removed", description: node.name, variant: "destructive" });
};
const loadNodeGraphs = async (nodeId, timeframe = graphTimeframe) => {
setGraphLoading(true);
try {
const payload = await appClient.proxmox.nodeUsageGraphs(nodeId, timeframe);
setGraphData(payload);
} catch (error) {
toast({
title: "Graph Load Failed",
description: error?.message || "Unable to fetch node usage graph",
variant: "destructive"
});
setGraphData(null);
} finally {
setGraphLoading(false);
}
};
const openGraphs = async (node) => {
setGraphNode(node);
setGraphTimeframe("day");
setShowGraphs(true);
await loadNodeGraphs(node.id, "day");
};
const formatUptime = (seconds) => {
if (!seconds) return "N/A";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return `${days}d ${hours}h`;
};
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
}
return (
<div className="space-y-6">
<PageHeader title="Proxmox Nodes" description={`${nodes.length} nodes in cluster`}>
<Button onClick={() => setShowAdd(true)} className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
<Plus className="w-4 h-4" /> Add Node
</Button>
</PageHeader>
{nodes.length === 0 ? (
<EmptyState icon={HardDrive} title="No Nodes" description="Add your first Proxmox node to get started."
action={<Button onClick={() => setShowAdd(true)} variant="outline" className="gap-2"><Plus className="w-4 h-4" /> Add Node</Button>} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{nodes.map((node, i) => (
<motion.div key={node.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
className="surface-card p-5 hover:border-primary/20 transition-all">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${node.status === "online" ? "bg-emerald-50" : "bg-rose-50"}`}>
{node.status === "online" ? <Wifi className="w-5 h-5 text-emerald-600" /> : <WifiOff className="w-5 h-5 text-rose-600" />}
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">{node.name}</h3>
<p className="text-[11px] text-muted-foreground font-mono">{node.hostname}</p>
</div>
</div>
<StatusBadge status={node.status} />
</div>
<div className="space-y-3">
<ResourceBar label="CPU" percentage={node.cpu_usage || 0} />
<ResourceBar label="RAM" used={node.ram_used_mb || 0} total={node.ram_total_mb || 1} unit=" MB" />
<ResourceBar label="Disk" used={node.disk_used_gb || 0} total={node.disk_total_gb || 1} unit=" GB" />
</div>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex gap-4 text-[11px] text-muted-foreground">
<span>VMs: {node.vm_count || 0}</span>
<span>Uptime: {formatUptime(node.uptime_seconds)}</span>
<span>PVE {node.pve_version || "N/A"}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-sky-600 hover:bg-sky-50" onClick={() => openGraphs(node)}>
<LineChartIcon className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-rose-600 hover:bg-rose-50" onClick={() => handleDelete(node)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</motion.div>
))}
</div>
)}
<Dialog open={showAdd} onOpenChange={setShowAdd}>
<DialogContent className="bg-card border-border max-w-md">
<DialogHeader><DialogTitle>Add Proxmox Node</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Node Name</Label><Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="pve-node-01" className="bg-muted border-border mt-1" /></div>
<div><Label>Hostname / IP</Label><Input value={form.hostname} onChange={e => setForm({ ...form, hostname: e.target.value })} placeholder="192.168.1.100" className="bg-muted border-border mt-1" /></div>
<div className="grid grid-cols-3 gap-3">
<div><Label>CPU Cores</Label><Input type="number" value={form.cpu_cores} onChange={e => setForm({ ...form, cpu_cores: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div>
<div><Label>RAM (MB)</Label><Input type="number" value={form.ram_total_mb} onChange={e => setForm({ ...form, ram_total_mb: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div>
<div><Label>Disk (GB)</Label><Input type="number" value={form.disk_total_gb} onChange={e => setForm({ ...form, disk_total_gb: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAdd(false)}>Cancel</Button>
<Button onClick={handleAdd} disabled={saving || !form.name || !form.hostname} className="bg-primary text-primary-foreground">{saving ? "Adding..." : "Add Node"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showGraphs} onOpenChange={setShowGraphs}>
<DialogContent className="bg-card border-border max-w-5xl">
<DialogHeader>
<DialogTitle>Node Resource Graphs - {graphNode?.name || "Node"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
Source: {graphData?.source || "n/a"} | Host: {graphData?.node_hostname || graphNode?.hostname || "n/a"}
</div>
<Select
value={graphTimeframe}
onValueChange={async (value) => {
setGraphTimeframe(value);
if (graphNode?.id) {
await loadNodeGraphs(graphNode.id, value);
}
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{graphTimeframes.map((timeframe) => (
<SelectItem key={timeframe} value={timeframe}>
{timeframe.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{graphLoading ? (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
) : graphData?.points?.length > 0 ? (
<div className="space-y-4">
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={graphData.points}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="timestamp" tick={{ fontSize: 10 }} tickFormatter={formatGraphLabel} />
<YAxis tick={{ fontSize: 10 }} domain={[0, 100]} />
<Tooltip labelFormatter={formatGraphLabel} formatter={(value) => `${Number(value).toFixed(2)}%`} />
<Legend />
<Line type="monotone" dataKey="cpu_pct" stroke="#f97316" dot={false} strokeWidth={2} name="CPU %" />
<Line type="monotone" dataKey="ram_pct" stroke="#38bdf8" dot={false} strokeWidth={2} name="RAM %" />
<Line type="monotone" dataKey="disk_usage_pct" stroke="#22c55e" dot={false} strokeWidth={2} name="Disk %" />
<Line type="monotone" dataKey="io_wait_pct" stroke="#f43f5e" dot={false} strokeWidth={2} name="I/O Wait %" />
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-52 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={graphData.points}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="timestamp" tick={{ fontSize: 10 }} tickFormatter={formatGraphLabel} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip labelFormatter={formatGraphLabel} formatter={(value) => `${Number(value).toFixed(3)} MB/s`} />
<Legend />
<Line type="monotone" dataKey="network_in_mbps" stroke="#a78bfa" dot={false} strokeWidth={2} name="Net In MB/s" />
<Line type="monotone" dataKey="network_out_mbps" stroke="#06b6d4" dot={false} strokeWidth={2} name="Net Out MB/s" />
</LineChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 md:grid-cols-6 gap-2 text-xs">
<div className="rounded-lg border border-border p-2">Avg CPU: {graphData.summary?.avg_cpu_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Peak CPU: {graphData.summary?.peak_cpu_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Avg I/O Wait: {graphData.summary?.avg_io_wait_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Peak I/O Wait: {graphData.summary?.peak_io_wait_pct ?? 0}%</div>
<div className="rounded-lg border border-border p-2">Peak Net In: {graphData.summary?.peak_network_in_mbps ?? 0} MB/s</div>
<div className="rounded-lg border border-border p-2">Peak Net Out: {graphData.summary?.peak_network_out_mbps ?? 0} MB/s</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No graph samples available for this node and timeframe.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowGraphs(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

355
src/pages/Operations.jsx Normal file
View File

@@ -0,0 +1,355 @@
import { useEffect, useMemo, useState } from "react";
import moment from "moment";
import { appClient } from "@/api/appClient";
import { Play, Plus, RefreshCw, Trash2 } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import EmptyState from "../components/shared/EmptyState";
import StatusBadge from "../components/shared/StatusBadge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
const taskTypeOptions = [
"all",
"VM_POWER",
"VM_MIGRATION",
"VM_REINSTALL",
"VM_NETWORK",
"VM_CONFIG",
"VM_DELETE",
"SYSTEM_SYNC"
];
const statusOptions = ["all", "QUEUED", "RUNNING", "SUCCESS", "FAILED", "RETRYING", "CANCELED"];
const powerActionOptions = ["START", "STOP", "RESTART", "SHUTDOWN"];
export default function Operations() {
const [tasks, setTasks] = useState([]);
const [queueMeta, setQueueMeta] = useState({ total: 0, queue_summary: {} });
const [queueInsights, setQueueInsights] = useState({
stale_queued_tasks: 0,
failed_tasks_24h: 0,
due_retries: 0,
due_power_schedules: 0
});
const [schedules, setSchedules] = useState([]);
const [vms, setVms] = useState([]);
const [statusFilter, setStatusFilter] = useState("all");
const [taskTypeFilter, setTaskTypeFilter] = useState("all");
const [loading, setLoading] = useState(true);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [savingSchedule, setSavingSchedule] = useState(false);
const [scheduleForm, setScheduleForm] = useState({
vm_id: "",
action: "START",
cron_expression: "0 * * * *",
timezone: "UTC"
});
const { toast } = useToast();
const queueCards = useMemo(() => {
const summary = queueMeta.queue_summary || {};
return [
{ label: "Queued", value: summary.QUEUED || 0, color: "text-amber-600" },
{ label: "Running", value: summary.RUNNING || 0, color: "text-blue-600" },
{ label: "Failed", value: summary.FAILED || 0, color: "text-rose-600" },
{ label: "Success", value: summary.SUCCESS || 0, color: "text-emerald-600" }
];
}, [queueMeta]);
useEffect(() => {
loadAll();
}, [statusFilter, taskTypeFilter]);
const loadAll = async () => {
setLoading(true);
try {
const [tasksResult, queueInsightResult, schedulesResult, vmResult] = await Promise.all([
appClient.operations.listTasks({
status: statusFilter !== "all" ? statusFilter : undefined,
task_type: taskTypeFilter !== "all" ? taskTypeFilter : undefined,
limit: 100
}),
appClient.operations.queueInsights(),
appClient.operations.listPowerSchedules(),
appClient.entities.VirtualMachine.list("-created_at", 200)
]);
setTasks(tasksResult?.data || []);
setQueueMeta(tasksResult?.meta || { total: 0, queue_summary: {} });
setQueueInsights(queueInsightResult || {
stale_queued_tasks: 0,
failed_tasks_24h: 0,
due_retries: 0,
due_power_schedules: 0
});
setSchedules(schedulesResult?.data || []);
setVms(vmResult || []);
} catch (error) {
toast({
title: "Failed To Load Operations",
description: error?.message || "Unable to fetch operation telemetry",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleCreateSchedule = async () => {
if (!scheduleForm.vm_id) return;
setSavingSchedule(true);
try {
await appClient.operations.createPowerSchedule(scheduleForm);
toast({ title: "Power Schedule Created" });
setShowScheduleDialog(false);
setScheduleForm({ vm_id: "", action: "START", cron_expression: "0 * * * *", timezone: "UTC" });
await loadAll();
} catch (error) {
toast({
title: "Failed To Create Schedule",
description: error?.message || "Could not save power schedule",
variant: "destructive"
});
} finally {
setSavingSchedule(false);
}
};
const handleToggleSchedule = async (schedule) => {
try {
await appClient.operations.updatePowerSchedule(schedule.id, { enabled: !schedule.enabled });
await loadAll();
} catch (error) {
toast({ title: "Update Failed", description: error?.message || "Could not toggle schedule", variant: "destructive" });
}
};
const handleDeleteSchedule = async (schedule) => {
try {
await appClient.operations.deletePowerSchedule(schedule.id);
toast({ title: "Schedule Deleted", variant: "destructive" });
await loadAll();
} catch (error) {
toast({ title: "Delete Failed", description: error?.message || "Could not delete schedule", variant: "destructive" });
}
};
const handleRunNow = async (schedule) => {
try {
await appClient.operations.runPowerScheduleNow(schedule.id);
toast({ title: "Power Action Triggered", description: `Scheduled action ${schedule.action} started` });
await loadAll();
} catch (error) {
toast({ title: "Run Failed", description: error?.message || "Could not run schedule", variant: "destructive" });
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Operations Center" description="Task queue, lifecycle orchestration, and power schedules">
<div className="flex gap-2">
<Button variant="outline" className="gap-2" onClick={loadAll}><RefreshCw className="w-4 h-4" /> Refresh</Button>
<Button className="gap-2" onClick={() => setShowScheduleDialog(true)}><Plus className="w-4 h-4" /> New Power Schedule</Button>
</div>
</PageHeader>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{queueCards.map((card) => (
<div key={card.label} className="surface-card p-4">
<p className="text-xs uppercase tracking-wider text-muted-foreground">{card.label}</p>
<p className={`text-2xl font-semibold mt-1 ${card.color}`}>{card.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="surface-card p-4">
<p className="text-xs uppercase tracking-wider text-muted-foreground">Stale Queued</p>
<p className="text-2xl font-semibold mt-1 text-orange-600">{queueInsights.stale_queued_tasks || 0}</p>
</div>
<div className="surface-card p-4">
<p className="text-xs uppercase tracking-wider text-muted-foreground">Due Retries</p>
<p className="text-2xl font-semibold mt-1 text-blue-600">{queueInsights.due_retries || 0}</p>
</div>
<div className="surface-card p-4">
<p className="text-xs uppercase tracking-wider text-muted-foreground">Failed (24h)</p>
<p className="text-2xl font-semibold mt-1 text-rose-600">{queueInsights.failed_tasks_24h || 0}</p>
</div>
<div className="surface-card p-4">
<p className="text-xs uppercase tracking-wider text-muted-foreground">Due Power Schedules</p>
<p className="text-2xl font-semibold mt-1 text-sky-600">{queueInsights.due_power_schedules || 0}</p>
</div>
</div>
<div className="surface-card p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label>Status Filter</Label>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
{statusOptions.map((status) => (<SelectItem key={status} value={status}>{status}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label>Task Type</Label>
<Select value={taskTypeFilter} onValueChange={setTaskTypeFilter}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
{taskTypeOptions.map((type) => (<SelectItem key={type} value={type}>{type}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
</div>
{tasks.length === 0 ? (
<EmptyState title="No Operation Tasks" description="Run VM operations or schedules to populate task history." />
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{[
"Created",
"Type",
"Status",
"VM",
"Node",
"Requested By",
"Result"
].map((header) => (
<th key={header} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">
{header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{tasks.map((task) => (
<tr key={task.id} className="hover:bg-muted/20">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{task.created_at ? moment(task.created_at).format("YYYY-MM-DD HH:mm:ss") : "-"}
</td>
<td className="px-4 py-3 text-xs font-mono text-foreground">{task.task_type}</td>
<td className="px-4 py-3"><StatusBadge status={task.status?.toLowerCase()} /></td>
<td className="px-4 py-3 text-sm text-foreground">{task.vm_name || "System"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground font-mono">{task.node || "-"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{task.requested_by || "system"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[260px] truncate">
{task.error_message || task.proxmox_upid || "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="space-y-3">
<h2 className="text-base font-semibold text-foreground">Power Schedules</h2>
{schedules.length === 0 ? (
<EmptyState title="No Power Schedules" description="Create cron-based start/stop/restart/shutdown policies for VMs." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{schedules.map((schedule) => (
<div key={schedule.id} className="surface-card p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">{schedule.vm?.name || "Unknown VM"}</p>
<p className="text-xs text-muted-foreground font-mono">{schedule.action} | {schedule.cron_expression}</p>
<p className="text-[11px] text-muted-foreground mt-1">
Next: {schedule.next_run_at ? moment(schedule.next_run_at).format("YYYY-MM-DD HH:mm") : "n/a"}
</p>
</div>
<Switch checked={schedule.enabled} onCheckedChange={() => handleToggleSchedule(schedule)} />
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1" onClick={() => handleRunNow(schedule)}>
<Play className="w-3.5 h-3.5" /> Run Now
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-rose-600 hover:bg-rose-50" onClick={() => handleDeleteSchedule(schedule)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
<DialogContent className="bg-card border-border max-w-md">
<DialogHeader>
<DialogTitle>Create Power Schedule</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Virtual Machine</Label>
<Select value={scheduleForm.vm_id} onValueChange={(value) => setScheduleForm((prev) => ({ ...prev, vm_id: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue placeholder="Select VM" /></SelectTrigger>
<SelectContent>
{vms.map((vm) => (<SelectItem key={vm.id} value={vm.id}>{vm.name}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label>Action</Label>
<Select value={scheduleForm.action} onValueChange={(value) => setScheduleForm((prev) => ({ ...prev, action: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
{powerActionOptions.map((action) => (<SelectItem key={action} value={action}>{action}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label>Cron Expression</Label>
<Input
value={scheduleForm.cron_expression}
onChange={(event) => setScheduleForm((prev) => ({ ...prev, cron_expression: event.target.value }))}
placeholder="e.g. 0 * * * *"
className="mt-1 bg-muted border-border font-mono"
/>
<p className="text-[11px] text-muted-foreground mt-1">5-field cron format: minute hour day month weekday</p>
</div>
<div>
<Label>Timezone</Label>
<Input value={scheduleForm.timezone} onChange={(event) => setScheduleForm((prev) => ({ ...prev, timezone: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowScheduleDialog(false)}>Cancel</Button>
<Button onClick={handleCreateSchedule} disabled={savingSchedule || !scheduleForm.vm_id}>
{savingSchedule ? "Saving..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

611
src/pages/Provisioning.jsx Normal file
View File

@@ -0,0 +1,611 @@
import { useEffect, useState } from "react";
import moment from "moment";
import { appClient } from "@/api/appClient";
import { Plus, RefreshCw, PauseCircle, PlayCircle, XCircle } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import EmptyState from "../components/shared/EmptyState";
import StatusBadge from "../components/shared/StatusBadge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/use-toast";
const lifecycleFilterOptions = ["all", "ACTIVE", "SUSPENDED", "TERMINATED"];
export default function Provisioning() {
const [loading, setLoading] = useState(true);
const [services, setServices] = useState([]);
const [templates, setTemplates] = useState([]);
const [groups, setGroups] = useState([]);
const [tenants, setTenants] = useState([]);
const [lifecycleFilter, setLifecycleFilter] = useState("all");
const [showCreateService, setShowCreateService] = useState(false);
const [creatingService, setCreatingService] = useState(false);
const [serviceForm, setServiceForm] = useState({
name: "",
tenant_id: "",
product_type: "VPS",
virtualization_type: "QEMU",
vm_count: 1,
auto_node: true,
target_node: "",
application_group_id: "",
template_id: "",
cpu_cores: 2,
ram_mb: 2048,
disk_gb: 40
});
const [showCreateTemplate, setShowCreateTemplate] = useState(false);
const [creatingTemplate, setCreatingTemplate] = useState(false);
const [templateForm, setTemplateForm] = useState({
name: "",
template_type: "APPLICATION",
virtualization_type: "QEMU",
source: "",
description: "",
default_cloud_init: "",
storage: "local-lvm",
bridge: "vmbr0",
auto_start: true,
full_clone: true,
ha_enabled: false,
ha_group: "",
ha_required: false
});
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [creatingGroup, setCreatingGroup] = useState(false);
const [groupForm, setGroupForm] = useState({
name: "",
description: ""
});
const { toast } = useToast();
useEffect(() => {
loadAll();
}, [lifecycleFilter]);
const loadAll = async () => {
setLoading(true);
try {
const [serviceResult, templateResult, groupResult, tenantResult] = await Promise.all([
appClient.provisioning.listServices({
lifecycle_status: lifecycleFilter !== "all" ? lifecycleFilter : undefined,
limit: 200
}),
appClient.provisioning.listTemplates(),
appClient.provisioning.listApplicationGroups(),
appClient.entities.Tenant.list("-created_at", 200)
]);
setServices(serviceResult?.data || []);
setTemplates(templateResult?.data || []);
setGroups(groupResult?.data || []);
setTenants(tenantResult || []);
if (!serviceForm.tenant_id && (tenantResult || []).length > 0) {
setServiceForm((prev) => ({ ...prev, tenant_id: tenantResult[0].id }));
}
} catch (error) {
toast({ title: "Load Failed", description: error?.message || "Could not load provisioning data", variant: "destructive" });
} finally {
setLoading(false);
}
};
const handleCreateService = async () => {
if (!serviceForm.name || !serviceForm.tenant_id) return;
setCreatingService(true);
try {
await appClient.provisioning.createService({
name: serviceForm.name,
tenant_id: serviceForm.tenant_id,
product_type: serviceForm.product_type,
virtualization_type: serviceForm.virtualization_type,
vm_count: Number(serviceForm.vm_count),
auto_node: serviceForm.auto_node,
target_node: serviceForm.auto_node ? undefined : serviceForm.target_node || undefined,
application_group_id: serviceForm.application_group_id || undefined,
template_id: serviceForm.template_id || undefined,
package_options: {
cpu_cores: Number(serviceForm.cpu_cores),
ram_mb: Number(serviceForm.ram_mb),
disk_gb: Number(serviceForm.disk_gb)
}
});
toast({ title: "Service Provisioned" });
setShowCreateService(false);
await loadAll();
} catch (error) {
toast({ title: "Provisioning Failed", description: error?.message || "Could not create service", variant: "destructive" });
} finally {
setCreatingService(false);
}
};
const handleCreateTemplate = async () => {
if (!templateForm.name) return;
setCreatingTemplate(true);
try {
await appClient.provisioning.createTemplate({
name: templateForm.name,
template_type: templateForm.template_type,
virtualization_type: templateForm.template_type === "APPLICATION" ? undefined : templateForm.virtualization_type,
source: templateForm.source || undefined,
description: templateForm.description || undefined,
default_cloud_init: templateForm.default_cloud_init || undefined,
metadata: {
storage: templateForm.storage || "local-lvm",
bridge: templateForm.bridge || "vmbr0",
auto_start: Boolean(templateForm.auto_start),
full_clone: Boolean(templateForm.full_clone),
ha: templateForm.ha_enabled
? {
enabled: true,
group: templateForm.ha_group || undefined,
required: Boolean(templateForm.ha_required)
}
: {
enabled: false
}
}
});
toast({ title: "Template Created" });
setShowCreateTemplate(false);
setTemplateForm({
name: "",
template_type: "APPLICATION",
virtualization_type: "QEMU",
source: "",
description: "",
default_cloud_init: "",
storage: "local-lvm",
bridge: "vmbr0",
auto_start: true,
full_clone: true,
ha_enabled: false,
ha_group: "",
ha_required: false
});
await loadAll();
} catch (error) {
toast({ title: "Template Create Failed", description: error?.message || "Could not create template", variant: "destructive" });
} finally {
setCreatingTemplate(false);
}
};
const handleCreateGroup = async () => {
if (!groupForm.name) return;
setCreatingGroup(true);
try {
await appClient.provisioning.createApplicationGroup({
name: groupForm.name,
description: groupForm.description || undefined
});
toast({ title: "Application Group Created" });
setShowCreateGroup(false);
setGroupForm({ name: "", description: "" });
await loadAll();
} catch (error) {
toast({ title: "Group Create Failed", description: error?.message || "Could not create group", variant: "destructive" });
} finally {
setCreatingGroup(false);
}
};
const handleSuspend = async (service) => {
try {
await appClient.provisioning.suspendService(service.id, { reason: "Suspended from admin console" });
toast({ title: "Service Suspended" });
await loadAll();
} catch (error) {
toast({ title: "Suspend Failed", description: error?.message || "Could not suspend service", variant: "destructive" });
}
};
const handleUnsuspend = async (service) => {
try {
await appClient.provisioning.unsuspendService(service.id);
toast({ title: "Service Activated" });
await loadAll();
} catch (error) {
toast({ title: "Unsuspend Failed", description: error?.message || "Could not activate service", variant: "destructive" });
}
};
const handleTerminate = async (service) => {
try {
await appClient.provisioning.terminateService(service.id, { reason: "Terminated from admin console", hard_delete: false });
toast({ title: "Service Terminated", variant: "destructive" });
await loadAll();
} catch (error) {
toast({ title: "Terminate Failed", description: error?.message || "Could not terminate service", variant: "destructive" });
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Provisioning" description="Templates, placement groups, and service lifecycle operations">
<div className="flex gap-2">
<Button variant="outline" className="gap-2" onClick={loadAll}><RefreshCw className="w-4 h-4" /> Refresh</Button>
<Button className="gap-2" onClick={() => setShowCreateService(true)}><Plus className="w-4 h-4" /> New Service</Button>
</div>
</PageHeader>
<Tabs defaultValue="services" className="space-y-4">
<TabsList className="bg-muted">
<TabsTrigger value="services">Services</TabsTrigger>
<TabsTrigger value="templates">Templates</TabsTrigger>
<TabsTrigger value="groups">App Groups</TabsTrigger>
</TabsList>
<TabsContent value="services" className="space-y-4">
<div className="surface-card p-4">
<Label>Lifecycle Filter</Label>
<Select value={lifecycleFilter} onValueChange={setLifecycleFilter}>
<SelectTrigger className="mt-1 max-w-[220px] bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
{lifecycleFilterOptions.map((option) => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{services.length === 0 ? (
<EmptyState title="No Provisioned Services" description="Create VPS or cloud services to begin lifecycle management." />
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{["Service", "Tenant", "Product", "VM", "Status", "Created", "Actions"].map((header) => (
<th key={header} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{header}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{services.map((service) => (
<tr key={service.id} className="hover:bg-muted/20">
<td className="px-4 py-3">
<span className="text-sm font-medium text-foreground">{service.vm?.name || service.id}</span>
<p className="text-[11px] text-muted-foreground font-mono">{service.id}</p>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{service.tenant?.name || service.tenant_id}</td>
<td className="px-4 py-3 text-xs font-mono text-foreground">{service.product_type}</td>
<td className="px-4 py-3 text-xs text-muted-foreground font-mono">{service.vm?.node} / VMID {service.vm?.vmid}</td>
<td className="px-4 py-3"><StatusBadge status={(service.lifecycle_status || "").toLowerCase()} /></td>
<td className="px-4 py-3 text-xs text-muted-foreground">{service.created_at ? moment(service.created_at).fromNow() : "-"}</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{service.lifecycle_status === "ACTIVE" && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-600 hover:bg-amber-50" onClick={() => handleSuspend(service)}>
<PauseCircle className="w-4 h-4" />
</Button>
)}
{service.lifecycle_status === "SUSPENDED" && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-emerald-600 hover:bg-emerald-50" onClick={() => handleUnsuspend(service)}>
<PlayCircle className="w-4 h-4" />
</Button>
)}
{service.lifecycle_status !== "TERMINATED" && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-rose-600 hover:bg-rose-50" onClick={() => handleTerminate(service)}>
<XCircle className="w-4 h-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</TabsContent>
<TabsContent value="templates" className="space-y-4">
<div className="flex justify-end">
<Button className="gap-2" onClick={() => setShowCreateTemplate(true)}><Plus className="w-4 h-4" /> New Template</Button>
</div>
{templates.length === 0 ? (
<EmptyState title="No Templates" description="Create template catalog entries for provisioning workflows." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => (
<div key={template.id} className="surface-card p-4">
<h3 className="text-sm font-semibold text-foreground">{template.name}</h3>
<p className="text-xs text-muted-foreground font-mono mt-1">{template.template_type}</p>
{template.source && <p className="text-[11px] text-muted-foreground mt-1 font-mono">{template.source}</p>}
<p className="text-[11px] text-muted-foreground mt-2">{template.description || "No description"}</p>
{template.default_cloud_init && (
<p className="text-[11px] text-muted-foreground mt-2">Cloud-Init: {template.default_cloud_init}</p>
)}
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="groups" className="space-y-4">
<div className="flex justify-end">
<Button className="gap-2" onClick={() => setShowCreateGroup(true)}><Plus className="w-4 h-4" /> New Group</Button>
</div>
{groups.length === 0 ? (
<EmptyState title="No Application Groups" description="Define groups to drive template assignment and placement policy." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{groups.map((group) => (
<div key={group.id} className="surface-card p-4">
<h3 className="text-sm font-semibold text-foreground">{group.name}</h3>
<p className="text-[11px] text-muted-foreground mt-1">{group.description || "No description"}</p>
<p className="text-[11px] text-muted-foreground mt-2">Templates: {group.templates?.length || 0}</p>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
<Dialog open={showCreateService} onOpenChange={setShowCreateService}>
<DialogContent className="bg-card border-border max-w-2xl">
<DialogHeader><DialogTitle>Provision Service</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Service Name</Label>
<Input value={serviceForm.name} onChange={(event) => setServiceForm((prev) => ({ ...prev, name: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Tenant</Label>
<Select value={serviceForm.tenant_id} onValueChange={(value) => setServiceForm((prev) => ({ ...prev, tenant_id: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue placeholder="Select tenant" /></SelectTrigger>
<SelectContent>
{tenants.map((tenant) => <SelectItem key={tenant.id} value={tenant.id}>{tenant.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label>Product</Label>
<Select value={serviceForm.product_type} onValueChange={(value) => setServiceForm((prev) => ({ ...prev, product_type: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="VPS">VPS</SelectItem>
<SelectItem value="CLOUD">Cloud</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Virtualization</Label>
<Select value={serviceForm.virtualization_type} onValueChange={(value) => setServiceForm((prev) => ({ ...prev, virtualization_type: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="QEMU">QEMU</SelectItem>
<SelectItem value="LXC">LXC</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>VM Count</Label>
<Input type="number" min={1} max={20} value={serviceForm.vm_count} onChange={(event) => setServiceForm((prev) => ({ ...prev, vm_count: Number(event.target.value) }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Auto Node</Label>
<Select value={serviceForm.auto_node ? "yes" : "no"} onValueChange={(value) => setServiceForm((prev) => ({ ...prev, auto_node: value === "yes" }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{!serviceForm.auto_node && (
<div>
<Label>Target Node Hostname</Label>
<Input value={serviceForm.target_node} onChange={(event) => setServiceForm((prev) => ({ ...prev, target_node: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div>
<Label>CPU Cores</Label>
<Input type="number" min={1} value={serviceForm.cpu_cores} onChange={(event) => setServiceForm((prev) => ({ ...prev, cpu_cores: Number(event.target.value) }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>RAM (MB)</Label>
<Input type="number" min={512} value={serviceForm.ram_mb} onChange={(event) => setServiceForm((prev) => ({ ...prev, ram_mb: Number(event.target.value) }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Disk (GB)</Label>
<Input type="number" min={5} value={serviceForm.disk_gb} onChange={(event) => setServiceForm((prev) => ({ ...prev, disk_gb: Number(event.target.value) }))} className="mt-1 bg-muted border-border" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Template</Label>
<Select value={serviceForm.template_id || "none"} onValueChange={(value) => setServiceForm((prev) => ({ ...prev, template_id: value === "none" ? "" : value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{templates.map((template) => <SelectItem key={template.id} value={template.id}>{template.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Application Group</Label>
<Select value={serviceForm.application_group_id || "none"} onValueChange={(value) => setServiceForm((prev) => ({ ...prev, application_group_id: value === "none" ? "" : value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{groups.map((group) => <SelectItem key={group.id} value={group.id}>{group.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateService(false)}>Cancel</Button>
<Button onClick={handleCreateService} disabled={creatingService || !serviceForm.name || !serviceForm.tenant_id}>
{creatingService ? "Provisioning..." : "Provision"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreateTemplate} onOpenChange={setShowCreateTemplate}>
<DialogContent className="bg-card border-border max-w-md">
<DialogHeader><DialogTitle>Create Template</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>Name</Label>
<Input value={templateForm.name} onChange={(event) => setTemplateForm((prev) => ({ ...prev, name: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Template Type</Label>
<Select value={templateForm.template_type} onValueChange={(value) => setTemplateForm((prev) => ({ ...prev, template_type: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="APPLICATION">Application</SelectItem>
<SelectItem value="KVM_TEMPLATE">KVM Template</SelectItem>
<SelectItem value="LXC_TEMPLATE">LXC Template</SelectItem>
<SelectItem value="ISO_IMAGE">ISO Image</SelectItem>
<SelectItem value="ARCHIVE">Archive</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Virtualization</Label>
<Select value={templateForm.virtualization_type} onValueChange={(value) => setTemplateForm((prev) => ({ ...prev, virtualization_type: value }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="QEMU">QEMU</SelectItem>
<SelectItem value="LXC">LXC</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Source</Label>
<Input value={templateForm.source} onChange={(event) => setTemplateForm((prev) => ({ ...prev, source: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Description</Label>
<Input value={templateForm.description} onChange={(event) => setTemplateForm((prev) => ({ ...prev, description: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Cloud-Init Snippet</Label>
<Input value={templateForm.default_cloud_init} onChange={(event) => setTemplateForm((prev) => ({ ...prev, default_cloud_init: event.target.value }))} placeholder="local:snippets/base.yml" className="mt-1 bg-muted border-border" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Default Storage</Label>
<Input value={templateForm.storage} onChange={(event) => setTemplateForm((prev) => ({ ...prev, storage: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Default Bridge</Label>
<Input value={templateForm.bridge} onChange={(event) => setTemplateForm((prev) => ({ ...prev, bridge: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Auto Start</Label>
<Select value={templateForm.auto_start ? "yes" : "no"} onValueChange={(value) => setTemplateForm((prev) => ({ ...prev, auto_start: value === "yes" }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Full Clone</Label>
<Select value={templateForm.full_clone ? "yes" : "no"} onValueChange={(value) => setTemplateForm((prev) => ({ ...prev, full_clone: value === "yes" }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>HA Enabled</Label>
<Select value={templateForm.ha_enabled ? "yes" : "no"} onValueChange={(value) => setTemplateForm((prev) => ({ ...prev, ha_enabled: value === "yes" }))}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no">No</SelectItem>
<SelectItem value="yes">Yes</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>HA Required</Label>
<Select value={templateForm.ha_required ? "yes" : "no"} onValueChange={(value) => setTemplateForm((prev) => ({ ...prev, ha_required: value === "yes" }))} disabled={!templateForm.ha_enabled}>
<SelectTrigger className="mt-1 bg-muted border-border"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="no">No</SelectItem>
<SelectItem value="yes">Yes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{templateForm.ha_enabled && (
<div>
<Label>HA Group</Label>
<Input value={templateForm.ha_group} onChange={(event) => setTemplateForm((prev) => ({ ...prev, ha_group: event.target.value }))} placeholder="Optional HA group" className="mt-1 bg-muted border-border" />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateTemplate(false)}>Cancel</Button>
<Button onClick={handleCreateTemplate} disabled={creatingTemplate || !templateForm.name}>{creatingTemplate ? "Saving..." : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreateGroup} onOpenChange={setShowCreateGroup}>
<DialogContent className="bg-card border-border max-w-md">
<DialogHeader><DialogTitle>Create Application Group</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label>Name</Label>
<Input value={groupForm.name} onChange={(event) => setGroupForm((prev) => ({ ...prev, name: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
<div>
<Label>Description</Label>
<Input value={groupForm.description} onChange={(event) => setGroupForm((prev) => ({ ...prev, description: event.target.value }))} className="mt-1 bg-muted border-border" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateGroup(false)}>Cancel</Button>
<Button onClick={handleCreateGroup} disabled={creatingGroup || !groupForm.name}>{creatingGroup ? "Saving..." : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

170
src/pages/RBAC.jsx Normal file
View File

@@ -0,0 +1,170 @@
import { useState, useEffect } from "react";
import { appClient } from "@/api/appClient";
import { Shield, Users } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import EmptyState from "../components/shared/EmptyState";
import { useToast } from "@/components/ui/use-toast";
import { motion } from "framer-motion";
const defaultRoles = [
{
name: "Super Admin",
description: "Full system access with no restrictions",
permissions: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop", "node:manage", "tenant:manage", "billing:manage", "backup:manage", "user:manage", "rbac:manage", "settings:manage", "audit:read"],
color: "text-rose-600",
bg: "bg-rose-50",
},
{
name: "Tenant Admin",
description: "Manage VMs and billing within tenant scope",
permissions: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop", "backup:manage", "billing:read", "audit:read"],
color: "text-indigo-600",
bg: "bg-indigo-50",
},
{
name: "Operator",
description: "Start/stop VMs and run backups",
permissions: ["vm:read", "vm:start", "vm:stop", "backup:manage", "audit:read"],
color: "text-sky-600",
bg: "bg-sky-50",
},
{
name: "Viewer",
description: "Read-only access to dashboards and VMs",
permissions: ["vm:read", "billing:read", "audit:read"],
color: "text-emerald-600",
bg: "bg-emerald-50",
},
];
const allPermissions = [
{ group: "Virtual Machines", items: ["vm:create", "vm:read", "vm:update", "vm:delete", "vm:start", "vm:stop"] },
{ group: "Nodes", items: ["node:manage"] },
{ group: "Tenants", items: ["tenant:manage"] },
{ group: "Billing", items: ["billing:manage", "billing:read"] },
{ group: "Backups", items: ["backup:manage"] },
{ group: "Users", items: ["user:manage"] },
{ group: "RBAC", items: ["rbac:manage"] },
{ group: "Settings", items: ["settings:manage"] },
{ group: "Audit", items: ["audit:read"] },
];
export default function RBAC() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
appClient.entities.User.list().then(data => { setUsers(data); setLoading(false); });
}, []);
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
return (
<div className="space-y-6">
<PageHeader title="Access Control" description="Role-based permissions management" />
{/* Roles */}
<div>
<h2 className="text-sm font-semibold text-foreground mb-3">Roles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{defaultRoles.map((role, i) => (
<motion.div key={role.name} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
className="surface-card p-5 hover:border-primary/20 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-9 h-9 rounded-lg ${role.bg} flex items-center justify-center`}>
<Shield className={`w-4.5 h-4.5 ${role.color}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">{role.name}</h3>
<p className="text-[11px] text-muted-foreground">{role.description}</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{role.permissions.map(p => (
<span key={p} className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground font-mono">{p}</span>
))}
</div>
</motion.div>
))}
</div>
</div>
{/* Permission Matrix */}
<div>
<h2 className="text-sm font-semibold text-foreground mb-3">Permission Matrix</h2>
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">Permission</th>
{defaultRoles.map(r => (
<th key={r.name} className="text-center text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{r.name}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{allPermissions.map(group => (
group.items.map((perm, j) => (
<tr key={perm} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-2.5">
{j === 0 && <p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">{group.group}</p>}
<span className="text-xs font-mono text-foreground">{perm}</span>
</td>
{defaultRoles.map(r => (
<td key={r.name} className="text-center px-4 py-2.5">
{r.permissions.includes(perm) ? (
<span className="inline-block w-5 h-5 rounded-md bg-primary/20 text-primary text-xs leading-5"></span>
) : (
<span className="inline-block w-5 h-5 rounded-md bg-muted text-muted-foreground/30 text-xs leading-5"></span>
)}
</td>
))}
</tr>
))
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Users */}
<div>
<h2 className="text-sm font-semibold text-foreground mb-3">Users ({users.length})</h2>
{users.length === 0 ? (
<EmptyState icon={Users} title="No Users" description="Users will appear here once they register." />
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{["User", "Email", "Role", "Joined"].map(h => (
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map(u => (
<tr key={u.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-sm font-medium text-foreground">{u.full_name || "—"}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{u.email}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium capitalize">{u.role || "user"}</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{u.created_date ? new Date(u.created_date).toLocaleDateString() : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

545
src/pages/Security.jsx Normal file
View File

@@ -0,0 +1,545 @@
import { useState, useEffect } from "react";
import { appClient } from "@/api/appClient";
import {
Shield, ShieldAlert, ShieldCheck, Plus, Trash2,
Activity, Flame, Bug, Zap, Globe, Lock, Eye, Ban,
AlertTriangle, CheckCircle2,
Wifi
} from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
import moment from "moment";
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar } from "recharts";
const threatData = Array.from({ length: 24 }, (_, i) => ({
time: `${String(i).padStart(2, "0")}:00`,
blocked: Math.floor(Math.random() * 400 + 50),
allowed: Math.floor(Math.random() * 2000 + 500),
}));
const threatTypeConfig = {
ddos: { icon: Zap, label: "DDoS Attack", color: "red", bg: "bg-rose-50", text: "text-rose-600", border: "border-rose-200" },
malware: { icon: Bug, label: "Malware", color: "orange", bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" },
ransomware: { icon: Lock, label: "Ransomware", color: "red", bg: "bg-rose-50", text: "text-rose-600", border: "border-rose-200" },
brute_force: { icon: Flame, label: "Brute Force", color: "yellow", bg: "bg-amber-50", text: "text-amber-600", border: "border-amber-200" },
port_scan: { icon: Eye, label: "Port Scan", color: "blue", bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" },
intrusion: { icon: AlertTriangle, label: "Intrusion", color: "red", bg: "bg-rose-50", text: "text-rose-600", border: "border-rose-200" },
suspicious_traffic: { icon: Activity, label: "Suspicious Traffic", color: "yellow", bg: "bg-amber-50", text: "text-amber-600", border: "border-amber-200" },
firewall_block: { icon: Ban, label: "Firewall Block", color: "cyan", bg: "bg-sky-50", text: "text-sky-600", border: "border-sky-200" },
};
const severityConfig = {
low: { label: "Low", color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" },
medium: { label: "Medium", color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200" },
high: { label: "High", color: "text-orange-600", bg: "bg-orange-50", border: "border-orange-200" },
critical: { label: "Critical", color: "text-rose-600", bg: "bg-rose-50", border: "border-rose-200" },
};
const statusConfig = {
detected: { label: "Detected", color: "text-amber-600", icon: AlertTriangle },
blocked: { label: "Blocked", color: "text-rose-600", icon: Ban },
mitigated: { label: "Mitigated", color: "text-blue-600", icon: ShieldCheck },
investigating: { label: "Investigating", color: "text-indigo-600", icon: Eye },
resolved: { label: "Resolved", color: "text-emerald-600", icon: CheckCircle2 },
};
const protectionModules = [
{ key: "ddos_protection", label: "DDoS Protection", desc: "Rate limiting + scrubbing center routing for volumetric attacks", icon: Zap, color: "text-rose-600", bg: "bg-rose-50" },
{ key: "malware_scan", label: "Malware Scanner", desc: "Real-time file system scanning with ClamAV + heuristic engine", icon: Bug, color: "text-orange-600", bg: "bg-orange-50" },
{ key: "ransomware_shield", label: "Ransomware Shield", desc: "Behavioral analysis blocking encryption-pattern file access", icon: Lock, color: "text-indigo-600", bg: "bg-indigo-50" },
{ key: "ids_ips", label: "IDS / IPS", desc: "Snort-based intrusion detection and prevention system", icon: Eye, color: "text-blue-600", bg: "bg-blue-50" },
{ key: "waf", label: "Web App Firewall", desc: "OWASP rule-set WAF for HTTP/S traffic filtering", icon: Shield, color: "text-sky-600", bg: "bg-sky-50" },
{ key: "geo_blocking", label: "Geo-Blocking", desc: "Block traffic from high-risk countries and regions", icon: Globe, color: "text-emerald-600", bg: "bg-emerald-50" },
{ key: "bruteforce_protection", label: "Brute Force Guard", desc: "Auto-ban IPs after repeated failed auth attempts (fail2ban)", icon: Flame, color: "text-amber-600", bg: "bg-amber-50" },
{ key: "network_isolation", label: "Network Isolation", desc: "Micro-segmentation and tenant VLAN isolation", icon: Wifi, color: "text-indigo-600", bg: "bg-indigo-50" },
];
export default function Security() {
const [events, setEvents] = useState([]);
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddRule, setShowAddRule] = useState(false);
const [savingRule, setSavingRule] = useState(false);
const [modules, setModules] = useState(
Object.fromEntries(protectionModules.map(m => [m.key, true]))
);
const [ruleForm, setRuleForm] = useState({
name: "", direction: "inbound", action: "deny", protocol: "tcp",
source_ip: "", destination_ip: "", port_range: "", priority: 100,
applies_to: "all_nodes", enabled: true, description: ""
});
const { toast } = useToast();
useEffect(() => { loadData(); }, []);
const loadData = async () => {
const [evts, rls] = await Promise.all([
appClient.entities.SecurityEvent.list("-created_date", 50),
appClient.entities.FirewallRule.list("-priority"),
]);
setEvents(evts); setRules(rls); setLoading(false);
};
const handleAddRule = async () => {
setSavingRule(true);
await appClient.entities.FirewallRule.create({ ...ruleForm, hit_count: 0 });
await loadData();
setShowAddRule(false);
setSavingRule(false);
toast({ title: "Firewall Rule Added", description: ruleForm.name });
};
const handleDeleteRule = async (rule) => {
await appClient.entities.FirewallRule.delete(rule.id);
await loadData();
toast({ title: "Rule Deleted", variant: "destructive" });
};
const handleToggleRule = async (rule) => {
await appClient.entities.FirewallRule.update(rule.id, { enabled: !rule.enabled });
await loadData();
};
const handleResolve = async (event) => {
await appClient.entities.SecurityEvent.update(event.id, {
status: "resolved",
resolved_at: new Date().toISOString(),
action_taken: "Manually resolved by admin"
});
await loadData();
toast({ title: "Event Resolved", description: event.description || event.type });
};
const handleSimulate = async (type) => {
const config = threatTypeConfig[type];
const ips = ["45.33.22.11", "103.21.244.0", "198.51.100.14", "185.220.101.7", "91.108.4.0"];
await appClient.entities.SecurityEvent.create({
type,
severity: type === "ransomware" || type === "ddos" ? "critical" : type === "malware" || type === "intrusion" ? "high" : "medium",
status: "blocked",
target_node: "pve-node-01",
source_ip: ips[Math.floor(Math.random() * ips.length)],
destination_ip: "192.168.1.101",
destination_port: [22, 80, 443, 3389, 5900][Math.floor(Math.random() * 5)],
protocol: type === "ddos" ? "udp" : "tcp",
packets_per_second: type === "ddos" ? Math.floor(50000 + Math.random() * 200000) : undefined,
country: ["CN", "RU", "KP", "IR", "US"][Math.floor(Math.random() * 5)],
description: `${config.label} detected and automatically blocked by ProxPanel Security`,
action_taken: "Auto-blocked by security engine",
});
await loadData();
toast({
title: `${config.label} Simulated`,
description: "Threat detected and blocked by security engine.",
variant: "destructive"
});
};
const criticalCount = events.filter(e => e.severity === "critical").length;
const blockedCount = events.filter(e => e.status === "blocked" || e.status === "mitigated").length;
const activeThreats = events.filter(e => !["resolved", "blocked", "mitigated"].includes(e.status)).length;
const tooltipStyle = {
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "10px",
fontSize: "12px",
color: "hsl(var(--foreground))"
};
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
return (
<div className="space-y-6">
<PageHeader title="Security Center" description="Infrastructure threat detection, prevention & firewall management">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 border border-emerald-200">
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-xs font-medium text-emerald-600">Protected</span>
</div>
</div>
</PageHeader>
{/* Stat Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Threats Blocked", value: blockedCount, icon: ShieldCheck, color: "text-emerald-600", bg: "bg-emerald-50" },
{ label: "Active Threats", value: activeThreats, icon: ShieldAlert, color: "text-rose-600", bg: "bg-rose-50" },
{ label: "Critical Events", value: criticalCount, icon: AlertTriangle, color: "text-orange-600", bg: "bg-orange-50" },
{ label: "Firewall Rules", value: rules.length, icon: Shield, color: "text-sky-600", bg: "bg-sky-50" },
].map((stat, i) => (
<motion.div key={stat.label} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
className="surface-card p-4 hover:border-primary/20 transition-all">
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{stat.label}</p>
<p className="text-2xl font-bold text-foreground mt-1">{stat.value}</p>
</div>
<div className={`w-9 h-9 rounded-lg ${stat.bg} flex items-center justify-center`}>
<stat.icon className={`w-4.5 h-4.5 ${stat.color}`} />
</div>
</div>
</motion.div>
))}
</div>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="bg-muted flex-wrap h-auto gap-0.5 p-1">
<TabsTrigger value="overview" className="text-xs gap-1.5"><Activity className="w-3.5 h-3.5" />Overview</TabsTrigger>
<TabsTrigger value="modules" className="text-xs gap-1.5"><Shield className="w-3.5 h-3.5" />Protection</TabsTrigger>
<TabsTrigger value="events" className="text-xs gap-1.5"><ShieldAlert className="w-3.5 h-3.5" />Events</TabsTrigger>
<TabsTrigger value="firewall" className="text-xs gap-1.5"><Ban className="w-3.5 h-3.5" />Firewall</TabsTrigger>
<TabsTrigger value="simulate" className="text-xs gap-1.5"><Zap className="w-3.5 h-3.5" />Test</TabsTrigger>
</TabsList>
{/* ── OVERVIEW ── */}
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="surface-card p-5">
<h3 className="text-sm font-semibold text-foreground mb-4">Traffic Analysis (24h)</h3>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={threatData}>
<defs>
<linearGradient id="allowedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.25} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
<linearGradient id="blockedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--destructive))" stopOpacity={0.35} />
<stop offset="100%" stopColor="hsl(var(--destructive))" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="time" tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} />
<Area type="monotone" dataKey="allowed" stroke="hsl(var(--primary))" fill="url(#allowedGrad)" strokeWidth={2} name="Allowed" />
<Area type="monotone" dataKey="blocked" stroke="hsl(var(--destructive))" fill="url(#blockedGrad)" strokeWidth={2} name="Blocked" />
</AreaChart>
</ResponsiveContainer>
<div className="flex gap-4 mt-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"><div className="w-2.5 h-2.5 rounded-full bg-primary" />Allowed</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"><div className="w-2.5 h-2.5 rounded-full bg-destructive" />Blocked</div>
</div>
</div>
<div className="surface-card p-5">
<h3 className="text-sm font-semibold text-foreground mb-4">Threat Distribution</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={Object.entries(threatTypeConfig).map(([key, cfg]) => ({
name: cfg.label.split(" ")[0],
count: events.filter(e => e.type === key).length
}))}>
<XAxis dataKey="name" tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 9 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="count" fill="hsl(var(--destructive))" radius={[4, 4, 0, 0]} name="Events" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Recent Threat Events */}
<div className="surface-card p-5">
<h3 className="text-sm font-semibold text-foreground mb-4">Recent Security Events</h3>
{events.length === 0 ? (
<div className="flex items-center gap-3 py-8 justify-center">
<ShieldCheck className="w-8 h-8 text-emerald-600" />
<div>
<p className="text-sm font-medium text-foreground">All Clear</p>
<p className="text-xs text-muted-foreground">No threats detected</p>
</div>
</div>
) : (
<div className="space-y-2">
{events.slice(0, 6).map(evt => {
const typeCfg = threatTypeConfig[evt.type] || threatTypeConfig.suspicious_traffic;
const sevCfg = severityConfig[evt.severity] || severityConfig.medium;
const stCfg = statusConfig[evt.status] || statusConfig.detected;
return (
<div key={evt.id} className={`flex items-center gap-3 p-3 rounded-lg border ${typeCfg.bg} ${typeCfg.border}`}>
<typeCfg.icon className={`w-4 h-4 flex-shrink-0 ${typeCfg.text}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground">{typeCfg.label}</span>
<span className={`text-[11px] px-1.5 py-0.5 rounded-full border font-medium ${sevCfg.bg} ${sevCfg.color} ${sevCfg.border}`}>{sevCfg.label}</span>
<span className={`text-[11px] font-medium ${stCfg.color}`}>{stCfg.label}</span>
</div>
<p className="text-[11px] text-muted-foreground truncate">{evt.description || `From ${evt.source_ip || "unknown"}${evt.target_node || "node"}`}</p>
</div>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">{evt.created_date ? moment(evt.created_date).fromNow() : "—"}</span>
</div>
);
})}
</div>
)}
</div>
</TabsContent>
{/* ── PROTECTION MODULES ── */}
<TabsContent value="modules" className="space-y-4">
<div className="surface-card p-5 mb-4">
<div className="flex items-center gap-3 mb-1">
<ShieldCheck className="w-5 h-5 text-emerald-600" />
<h3 className="text-sm font-semibold text-foreground">ProxPanel Security Engine</h3>
</div>
<p className="text-xs text-muted-foreground ml-8">All protection modules run at the hypervisor level, providing OS-agnostic security across all VMs and containers.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{protectionModules.map((mod, i) => (
<motion.div key={mod.key} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.04 }}
className={`surface-card p-5 transition-all ${modules[mod.key] ? "border-border hover:border-primary/20" : "border-border opacity-60"}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${mod.bg} flex items-center justify-center flex-shrink-0 mt-0.5`}>
<mod.icon className={`w-5 h-5 ${mod.color}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-foreground">{mod.label}</h4>
{modules[mod.key] && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200 font-medium">ACTIVE</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{mod.desc}</p>
</div>
</div>
<Switch
checked={modules[mod.key]}
onCheckedChange={v => {
setModules(prev => ({ ...prev, [mod.key]: v }));
toast({ title: v ? `${mod.label} Enabled` : `${mod.label} Disabled` });
}}
/>
</div>
</motion.div>
))}
</div>
{/* DDoS Protection Details */}
<div className="surface-card p-5">
<h3 className="text-sm font-semibold text-foreground mb-4">DDoS Mitigation Thresholds</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ label: "PPS Threshold", value: "500,000", unit: "pkt/s", desc: "Auto-scrub trigger" },
{ label: "BPS Threshold", value: "1 Gbps", unit: "", desc: "Bandwidth limit" },
{ label: "Rate Limit", value: "10,000", unit: "req/s", desc: "Per-IP limit" },
].map(item => (
<div key={item.label} className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-lg font-bold text-foreground mt-0.5">{item.value} <span className="text-xs text-muted-foreground font-normal">{item.unit}</span></p>
<p className="text-[11px] text-muted-foreground">{item.desc}</p>
</div>
))}
</div>
</div>
</TabsContent>
{/* ── EVENTS ── */}
<TabsContent value="events" className="space-y-4">
{events.length === 0 ? (
<div className="surface-card p-12 text-center">
<ShieldCheck className="w-12 h-12 text-emerald-600 mx-auto mb-3" />
<h3 className="text-sm font-semibold text-foreground">No Security Events</h3>
<p className="text-xs text-muted-foreground mt-1">Your infrastructure is clean. Use the Test tab to simulate threats.</p>
</div>
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{["Time", "Type", "Severity", "Status", "Source IP", "Target", "Description", "Actions"].map(h => (
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
<AnimatePresence>
{events.map(evt => {
const typeCfg = threatTypeConfig[evt.type] || threatTypeConfig.suspicious_traffic;
const sevCfg = severityConfig[evt.severity] || severityConfig.medium;
const stCfg = statusConfig[evt.status] || statusConfig.detected;
return (
<motion.tr key={evt.id} initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-xs text-muted-foreground font-mono whitespace-nowrap">{evt.created_date ? moment(evt.created_date).format("MMM D HH:mm:ss") : "—"}</td>
<td className="px-4 py-3">
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-[11px] font-medium ${typeCfg.bg} ${typeCfg.text} ${typeCfg.border}`}>
<typeCfg.icon className="w-3 h-3" />{typeCfg.label}
</div>
</td>
<td className="px-4 py-3">
<span className={`text-[11px] px-2 py-0.5 rounded-full border font-medium ${sevCfg.bg} ${sevCfg.color} ${sevCfg.border}`}>{sevCfg.label}</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium flex items-center gap-1 ${stCfg.color}`}>
<stCfg.icon className="w-3.5 h-3.5" />{stCfg.label}
</span>
</td>
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">{evt.source_ip || "—"}{evt.country && <span className="ml-1 text-[10px]">({evt.country})</span>}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{evt.target_node || evt.target_vm || "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{evt.description || "—"}</td>
<td className="px-4 py-3">
{!["resolved"].includes(evt.status) && (
<Button variant="ghost" size="sm" onClick={() => handleResolve(evt)} className="text-emerald-600 hover:bg-emerald-50 text-xs h-7">Resolve</Button>
)}
</td>
</motion.tr>
);
})}
</AnimatePresence>
</tbody>
</table>
</div>
</div>
)}
</TabsContent>
{/* ── FIREWALL ── */}
<TabsContent value="firewall" className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setShowAddRule(true)} className="gap-2 bg-primary text-primary-foreground text-sm"><Plus className="w-3.5 h-3.5" /> Add Rule</Button>
</div>
{rules.length === 0 ? (
<div className="surface-card p-12 text-center">
<Shield className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
<h3 className="text-sm font-semibold text-foreground">No Firewall Rules</h3>
<p className="text-xs text-muted-foreground mt-1">Add rules to control traffic flow.</p>
</div>
) : (
<div className="surface-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
{["#", "Name", "Direction", "Action", "Protocol", "Source", "Port", "Applies To", "Hits", "Status", "Actions"].map(h => (
<th key={h} className="text-left text-[11px] font-medium text-muted-foreground uppercase tracking-wider px-4 py-3 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((rule, idx) => (
<tr key={rule.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-xs text-muted-foreground font-mono">{rule.priority || idx + 1}</td>
<td className="px-4 py-3">
<span className="text-sm font-medium text-foreground">{rule.name}</span>
{rule.description && <p className="text-[11px] text-muted-foreground">{rule.description}</p>}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground capitalize">{rule.direction}</td>
<td className="px-4 py-3">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium border ${rule.action === "allow" ? "bg-emerald-50 text-emerald-600 border-emerald-200" : rule.action === "deny" ? "bg-rose-50 text-rose-600 border-rose-200" : "bg-amber-50 text-amber-600 border-amber-200"}`}>
{rule.action?.toUpperCase()}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground font-mono uppercase">{rule.protocol}</td>
<td className="px-4 py-3 text-xs text-muted-foreground font-mono">{rule.source_ip || "ANY"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground font-mono">{rule.port_range || "ANY"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground capitalize">{rule.applies_to?.replace("_", " ")}</td>
<td className="px-4 py-3 text-xs text-muted-foreground font-mono">{(rule.hit_count || 0).toLocaleString()}</td>
<td className="px-4 py-3">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="icon" className="h-7 w-7 text-rose-600 hover:bg-rose-50" onClick={() => handleDeleteRule(rule)}><Trash2 className="w-3.5 h-3.5" /></Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</TabsContent>
{/* ── SIMULATE / TEST ── */}
<TabsContent value="simulate" className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-600">Security Test Mode</p>
<p className="text-xs text-muted-foreground mt-0.5">These simulations create sample security events to test your alert and response workflows. No actual attacks are generated.</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(threatTypeConfig).map(([key, cfg]) => (
<motion.div key={key} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
className={`surface-card p-5 hover:border-primary/20 transition-all cursor-pointer group`}
onClick={() => handleSimulate(key)}>
<div className={`w-10 h-10 rounded-lg ${cfg.bg} flex items-center justify-center mb-3`}>
<cfg.icon className={`w-5 h-5 ${cfg.text}`} />
</div>
<h4 className="text-sm font-semibold text-foreground">{cfg.label}</h4>
<p className="text-xs text-muted-foreground mt-1">Simulate a {cfg.label.toLowerCase()} attack event</p>
<div className="mt-3 flex items-center gap-1.5 text-xs text-primary opacity-0 group-hover:opacity-100 transition-opacity">
<Zap className="w-3.5 h-3.5" /> Run simulation
</div>
</motion.div>
))}
</div>
</TabsContent>
</Tabs>
{/* Add Firewall Rule Dialog */}
<Dialog open={showAddRule} onOpenChange={setShowAddRule}>
<DialogContent className="bg-card border-border max-w-xl">
<DialogHeader><DialogTitle>Add Firewall Rule</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Rule Name</Label><Input value={ruleForm.name} onChange={e => setRuleForm({ ...ruleForm, name: e.target.value })} placeholder="e.g. Block China SSH" className="bg-muted border-border mt-1" /></div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Direction</Label>
<Select value={ruleForm.direction} onValueChange={v => setRuleForm({ ...ruleForm, direction: v })}>
<SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="inbound">Inbound</SelectItem><SelectItem value="outbound">Outbound</SelectItem><SelectItem value="both">Both</SelectItem></SelectContent>
</Select>
</div>
<div>
<Label>Action</Label>
<Select value={ruleForm.action} onValueChange={v => setRuleForm({ ...ruleForm, action: v })}>
<SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="allow">Allow</SelectItem><SelectItem value="deny">Deny</SelectItem><SelectItem value="rate_limit">Rate Limit</SelectItem><SelectItem value="log">Log Only</SelectItem></SelectContent>
</Select>
</div>
<div>
<Label>Protocol</Label>
<Select value={ruleForm.protocol} onValueChange={v => setRuleForm({ ...ruleForm, protocol: v })}>
<SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="tcp">TCP</SelectItem><SelectItem value="udp">UDP</SelectItem><SelectItem value="icmp">ICMP</SelectItem><SelectItem value="any">Any</SelectItem></SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Source IP / CIDR</Label><Input value={ruleForm.source_ip} onChange={e => setRuleForm({ ...ruleForm, source_ip: e.target.value })} placeholder="0.0.0.0/0 or 1.2.3.4" className="bg-muted border-border mt-1 font-mono text-xs" /></div>
<div><Label>Port Range</Label><Input value={ruleForm.port_range} onChange={e => setRuleForm({ ...ruleForm, port_range: e.target.value })} placeholder="22 or 80-443" className="bg-muted border-border mt-1 font-mono text-xs" /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Applies To</Label>
<Select value={ruleForm.applies_to} onValueChange={v => setRuleForm({ ...ruleForm, applies_to: v })}>
<SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all_nodes">All Nodes</SelectItem><SelectItem value="all_vms">All VMs</SelectItem><SelectItem value="specific_node">Specific Node</SelectItem><SelectItem value="specific_vm">Specific VM</SelectItem></SelectContent>
</Select>
</div>
<div><Label>Priority</Label><Input type="number" value={ruleForm.priority} onChange={e => setRuleForm({ ...ruleForm, priority: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div>
</div>
<div><Label>Description</Label><Input value={ruleForm.description} onChange={e => setRuleForm({ ...ruleForm, description: e.target.value })} className="bg-muted border-border mt-1" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddRule(false)}>Cancel</Button>
<Button onClick={handleAddRule} disabled={savingRule || !ruleForm.name} className="bg-primary text-primary-foreground">{savingRule ? "Saving..." : "Add Rule"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

452
src/pages/Settings.jsx Normal file
View File

@@ -0,0 +1,452 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Clock3, CreditCard, Lock, Server, ShieldCheck, Wrench } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { appClient } from "@/api/appClient";
const defaultProxmox = {
host: "",
port: 8006,
username: "root@pam",
token_id: "",
token_secret: "",
verify_ssl: true
};
const defaultPayment = {
default_provider: "paystack",
paystack_public: "",
paystack_secret: "",
flutterwave_public: "",
flutterwave_secret: "",
flutterwave_webhook_hash: "",
callback_url: ""
};
const defaultBackup = {
default_source: "local",
default_storage: "local-lvm",
max_restore_file_count: 100,
pbs_enabled: false,
pbs_host: "",
pbs_datastore: "",
pbs_namespace: "",
pbs_verify_ssl: true
};
const defaultConsoleProxy = {
mode: "cluster",
cluster: {
novnc: "",
spice: "",
xterm: ""
},
nodes: {}
};
const defaultScheduler = {
enable_scheduler: true,
billing_cron: "0 * * * *",
backup_cron: "*/15 * * * *",
power_schedule_cron: "* * * * *",
monitoring_cron: "*/5 * * * *",
operation_retry_cron: "*/5 * * * *"
};
const defaultOperationsPolicy = {
max_retry_attempts: 2,
retry_backoff_minutes: 10,
notify_on_task_failure: true,
notification_email: "",
notification_webhook_url: "",
email_gateway_url: ""
};
const defaultNotifications = {
email_alerts: true,
backup_alerts: true,
billing_alerts: true,
vm_alerts: true,
monitoring_webhook_url: "",
alert_webhook_url: "",
email_gateway_url: "",
notification_email_webhook: "",
ops_email: ""
};
function compactPayload(data) {
const output = { ...data };
for (const [key, value] of Object.entries(output)) {
if (typeof value === "string" && value.trim().length === 0) {
delete output[key];
}
}
return output;
}
function schedulerStateLabel(status) {
if (!status) return "idle";
return String(status).toLowerCase();
}
export default function Settings() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState({
proxmox: false,
payment: false,
backup: false,
consoleProxy: false,
scheduler: false,
operations: false,
notifications: false
});
const [proxmox, setProxmox] = useState(defaultProxmox);
const [payment, setPayment] = useState(defaultPayment);
const [backup, setBackup] = useState(defaultBackup);
const [consoleProxy, setConsoleProxy] = useState(defaultConsoleProxy);
const [scheduler, setScheduler] = useState(defaultScheduler);
const [schedulerRuntime, setSchedulerRuntime] = useState(null);
const [operationsPolicy, setOperationsPolicy] = useState(defaultOperationsPolicy);
const [notifications, setNotifications] = useState(defaultNotifications);
useEffect(() => {
let active = true;
async function loadSettings() {
try {
setLoading(true);
const [
proxmoxData,
paymentData,
backupData,
consoleProxyData,
schedulerData,
operationsPolicyData,
notificationsData
] = await Promise.all([
appClient.settings.getProxmox(),
appClient.settings.getPayment(),
appClient.settings.getBackup(),
appClient.settings.getConsoleProxy(),
appClient.settings.getScheduler(),
appClient.settings.getOperationsPolicy(),
appClient.settings.getNotifications()
]);
if (!active) return;
setProxmox((prev) => ({ ...prev, ...(proxmoxData || {}) }));
setPayment((prev) => ({ ...prev, ...(paymentData || {}) }));
setBackup((prev) => ({ ...prev, ...(backupData || {}) }));
setConsoleProxy((prev) => ({
...prev,
...(consoleProxyData || {}),
cluster: {
...prev.cluster,
...(consoleProxyData?.cluster || {})
}
}));
setScheduler((prev) => ({ ...prev, ...(schedulerData?.config || {}) }));
setSchedulerRuntime(schedulerData?.runtime || null);
setOperationsPolicy((prev) => ({
...prev,
...(operationsPolicyData || {}),
notification_email: operationsPolicyData?.notification_email || "",
notification_webhook_url: operationsPolicyData?.notification_webhook_url || "",
email_gateway_url: operationsPolicyData?.email_gateway_url || ""
}));
setNotifications((prev) => ({ ...prev, ...(notificationsData || {}) }));
} catch (error) {
if (!active) return;
toast({
title: "Settings Load Failed",
description: error?.message || "Unable to load settings",
variant: "destructive"
});
} finally {
if (active) setLoading(false);
}
}
loadSettings();
return () => {
active = false;
};
}, [toast]);
const runtimeWorkers = useMemo(() => {
const workers = schedulerRuntime?.workers || {};
return Object.values(workers);
}, [schedulerRuntime]);
async function saveSection(key, saveFn) {
try {
setSaving((prev) => ({ ...prev, [key]: true }));
await saveFn();
toast({
title: "Settings Saved",
description: `${key} settings updated successfully`
});
} catch (error) {
toast({
title: "Save Failed",
description: error?.message || "Unable to save settings",
variant: "destructive"
});
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Settings" description="Enterprise control plane policies and runtime configuration" />
<Tabs defaultValue="scheduler" className="space-y-4">
<TabsList className="bg-muted flex-wrap h-auto gap-1 p-1">
<TabsTrigger value="scheduler" className="gap-1.5 text-xs"><Clock3 className="w-3.5 h-3.5" /> Scheduler</TabsTrigger>
<TabsTrigger value="operations" className="gap-1.5 text-xs"><Wrench className="w-3.5 h-3.5" /> Task Policy</TabsTrigger>
<TabsTrigger value="notifications" className="gap-1.5 text-xs"><Bell className="w-3.5 h-3.5" /> Notifications</TabsTrigger>
<TabsTrigger value="proxmox" className="gap-1.5 text-xs"><Server className="w-3.5 h-3.5" /> Proxmox</TabsTrigger>
<TabsTrigger value="payment" className="gap-1.5 text-xs"><CreditCard className="w-3.5 h-3.5" /> Payment</TabsTrigger>
<TabsTrigger value="backup" className="gap-1.5 text-xs"><ShieldCheck className="w-3.5 h-3.5" /> Backup</TabsTrigger>
<TabsTrigger value="console-proxy" className="gap-1.5 text-xs"><Lock className="w-3.5 h-3.5" /> Console Proxy</TabsTrigger>
</TabsList>
<TabsContent value="scheduler">
<div className="surface-card p-6 space-y-5">
<div>
<h3 className="text-sm font-semibold text-foreground mb-1">Cron Scheduler Settings</h3>
<p className="text-xs text-muted-foreground">Configure worker frequencies and apply changes live without backend restart.</p>
</div>
<div className="flex items-center gap-2">
<Switch checked={scheduler.enable_scheduler} onCheckedChange={(value) => setScheduler((prev) => ({ ...prev, enable_scheduler: value }))} />
<Label className="text-sm text-muted-foreground">Enable scheduler workers</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><Label>Billing Cron</Label><Input value={scheduler.billing_cron} onChange={(event) => setScheduler((prev) => ({ ...prev, billing_cron: event.target.value }))} className="mt-1 font-mono text-xs" /></div>
<div><Label>Backup Cron</Label><Input value={scheduler.backup_cron} onChange={(event) => setScheduler((prev) => ({ ...prev, backup_cron: event.target.value }))} className="mt-1 font-mono text-xs" /></div>
<div><Label>Power Schedule Cron</Label><Input value={scheduler.power_schedule_cron} onChange={(event) => setScheduler((prev) => ({ ...prev, power_schedule_cron: event.target.value }))} className="mt-1 font-mono text-xs" /></div>
<div><Label>Monitoring Cron</Label><Input value={scheduler.monitoring_cron} onChange={(event) => setScheduler((prev) => ({ ...prev, monitoring_cron: event.target.value }))} className="mt-1 font-mono text-xs" /></div>
<div><Label>Operation Retry Cron</Label><Input value={scheduler.operation_retry_cron} onChange={(event) => setScheduler((prev) => ({ ...prev, operation_retry_cron: event.target.value }))} className="mt-1 font-mono text-xs" /></div>
</div>
<Button
onClick={() =>
saveSection("scheduler", async () => {
const result = await appClient.settings.saveScheduler(scheduler);
setSchedulerRuntime(result?.runtime || null);
})
}
disabled={saving.scheduler}
>
{saving.scheduler ? "Saving..." : "Save Scheduler"}
</Button>
<div className="space-y-2 pt-3">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Runtime Worker State</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{runtimeWorkers.map((worker) => (
<div key={worker.worker} className="rounded-lg border border-border p-3">
<p className="text-sm font-medium text-foreground">{worker.worker}</p>
<p className="text-[11px] text-muted-foreground font-mono">{worker.cron}</p>
<p className="text-xs text-muted-foreground mt-1">Status: {schedulerStateLabel(worker.status)}</p>
<p className="text-[11px] text-muted-foreground">Last run: {worker.last_run_at ? new Date(worker.last_run_at).toLocaleString() : "Never"}</p>
</div>
))}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="operations">
<div className="surface-card p-6 space-y-5">
<div>
<h3 className="text-sm font-semibold text-foreground mb-1">Tasks Repetition Threshold And Failure Alerts</h3>
<p className="text-xs text-muted-foreground">Configure retry attempts, backoff, and notification destinations for failed operation tasks.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><Label>Max Retry Attempts</Label><Input type="number" min="0" max="10" value={operationsPolicy.max_retry_attempts} onChange={(event) => setOperationsPolicy((prev) => ({ ...prev, max_retry_attempts: Number(event.target.value || 0) }))} className="mt-1" /></div>
<div><Label>Retry Backoff (minutes)</Label><Input type="number" min="1" max="720" value={operationsPolicy.retry_backoff_minutes} onChange={(event) => setOperationsPolicy((prev) => ({ ...prev, retry_backoff_minutes: Number(event.target.value || 1) }))} className="mt-1" /></div>
<div><Label>Notification Email</Label><Input value={operationsPolicy.notification_email} onChange={(event) => setOperationsPolicy((prev) => ({ ...prev, notification_email: event.target.value }))} placeholder="ops@example.com" className="mt-1" /></div>
<div><Label>Email Gateway URL</Label><Input value={operationsPolicy.email_gateway_url} onChange={(event) => setOperationsPolicy((prev) => ({ ...prev, email_gateway_url: event.target.value }))} placeholder="https://hooks.example.com/email" className="mt-1 font-mono text-xs" /></div>
<div className="sm:col-span-2"><Label>Failure Webhook URL</Label><Input value={operationsPolicy.notification_webhook_url} onChange={(event) => setOperationsPolicy((prev) => ({ ...prev, notification_webhook_url: event.target.value }))} placeholder="https://hooks.example.com/task-failure" className="mt-1 font-mono text-xs" /></div>
</div>
<div className="flex items-center gap-2">
<Switch checked={operationsPolicy.notify_on_task_failure} onCheckedChange={(value) => setOperationsPolicy((prev) => ({ ...prev, notify_on_task_failure: value }))} />
<Label className="text-sm text-muted-foreground">Send notifications when retries are exhausted</Label>
</div>
<Button
onClick={() =>
saveSection("operations", async () => {
const payload = compactPayload(operationsPolicy);
const result = await appClient.settings.saveOperationsPolicy(payload);
setOperationsPolicy((prev) => ({
...prev,
...(result || {}),
notification_email: result?.notification_email || "",
notification_webhook_url: result?.notification_webhook_url || "",
email_gateway_url: result?.email_gateway_url || ""
}));
})
}
disabled={saving.operations}
>
{saving.operations ? "Saving..." : "Save Task Policy"}
</Button>
</div>
</TabsContent>
<TabsContent value="notifications">
<div className="surface-card p-6 space-y-5">
<div>
<h3 className="text-sm font-semibold text-foreground mb-1">Global Notification Routing</h3>
<p className="text-xs text-muted-foreground">Default routing for monitoring and operational alert deliveries.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><Label>Ops Email</Label><Input value={notifications.ops_email} onChange={(event) => setNotifications((prev) => ({ ...prev, ops_email: event.target.value }))} placeholder="ops@example.com" className="mt-1" /></div>
<div><Label>Email Gateway URL</Label><Input value={notifications.email_gateway_url} onChange={(event) => setNotifications((prev) => ({ ...prev, email_gateway_url: event.target.value }))} placeholder="https://hooks.example.com/email" className="mt-1 font-mono text-xs" /></div>
<div><Label>Monitoring Webhook URL</Label><Input value={notifications.monitoring_webhook_url} onChange={(event) => setNotifications((prev) => ({ ...prev, monitoring_webhook_url: event.target.value }))} placeholder="https://hooks.example.com/monitoring" className="mt-1 font-mono text-xs" /></div>
<div><Label>Alert Webhook URL</Label><Input value={notifications.alert_webhook_url} onChange={(event) => setNotifications((prev) => ({ ...prev, alert_webhook_url: event.target.value }))} placeholder="https://hooks.example.com/alerts" className="mt-1 font-mono text-xs" /></div>
<div><Label>Notification Email Webhook</Label><Input value={notifications.notification_email_webhook} onChange={(event) => setNotifications((prev) => ({ ...prev, notification_email_webhook: event.target.value }))} placeholder="https://hooks.example.com/notification-email" className="mt-1 font-mono text-xs" /></div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="rounded-lg border border-border p-3 flex items-center justify-between"><Label className="text-xs">Email Alerts</Label><Switch checked={notifications.email_alerts} onCheckedChange={(value) => setNotifications((prev) => ({ ...prev, email_alerts: value }))} /></div>
<div className="rounded-lg border border-border p-3 flex items-center justify-between"><Label className="text-xs">Backup Alerts</Label><Switch checked={notifications.backup_alerts} onCheckedChange={(value) => setNotifications((prev) => ({ ...prev, backup_alerts: value }))} /></div>
<div className="rounded-lg border border-border p-3 flex items-center justify-between"><Label className="text-xs">Billing Alerts</Label><Switch checked={notifications.billing_alerts} onCheckedChange={(value) => setNotifications((prev) => ({ ...prev, billing_alerts: value }))} /></div>
<div className="rounded-lg border border-border p-3 flex items-center justify-between"><Label className="text-xs">VM Alerts</Label><Switch checked={notifications.vm_alerts} onCheckedChange={(value) => setNotifications((prev) => ({ ...prev, vm_alerts: value }))} /></div>
</div>
<Button
onClick={() =>
saveSection("notifications", async () => {
const payload = compactPayload(notifications);
await appClient.settings.saveNotifications(payload);
})
}
disabled={saving.notifications}
>
{saving.notifications ? "Saving..." : "Save Notifications"}
</Button>
</div>
</TabsContent>
<TabsContent value="proxmox">
<div className="surface-card p-6 space-y-5">
<h3 className="text-sm font-semibold text-foreground">Proxmox API Connection</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><Label>Host</Label><Input value={proxmox.host} onChange={(event) => setProxmox((prev) => ({ ...prev, host: event.target.value }))} className="mt-1" /></div>
<div><Label>Port</Label><Input type="number" value={proxmox.port} onChange={(event) => setProxmox((prev) => ({ ...prev, port: Number(event.target.value || 8006) }))} className="mt-1" /></div>
<div><Label>Username</Label><Input value={proxmox.username} onChange={(event) => setProxmox((prev) => ({ ...prev, username: event.target.value }))} className="mt-1" /></div>
<div><Label>Token ID</Label><Input value={proxmox.token_id} onChange={(event) => setProxmox((prev) => ({ ...prev, token_id: event.target.value }))} className="mt-1" /></div>
<div className="sm:col-span-2"><Label>Token Secret</Label><Input type="password" value={proxmox.token_secret} onChange={(event) => setProxmox((prev) => ({ ...prev, token_secret: event.target.value }))} className="mt-1" /></div>
</div>
<div className="flex items-center gap-2">
<Switch checked={proxmox.verify_ssl} onCheckedChange={(value) => setProxmox((prev) => ({ ...prev, verify_ssl: value }))} />
<Label className="text-sm text-muted-foreground">Verify SSL certificate</Label>
</div>
<Button onClick={() => saveSection("proxmox", () => appClient.settings.saveProxmox(compactPayload(proxmox)))} disabled={saving.proxmox}>
{saving.proxmox ? "Saving..." : "Save Proxmox"}
</Button>
</div>
</TabsContent>
<TabsContent value="payment">
<div className="surface-card p-6 space-y-5">
<h3 className="text-sm font-semibold text-foreground">Payment Gateway Settings</h3>
<div>
<Label>Default Provider</Label>
<Select value={payment.default_provider} onValueChange={(value) => setPayment((prev) => ({ ...prev, default_provider: value }))}>
<SelectTrigger className="mt-1 max-w-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="paystack">Paystack</SelectItem>
<SelectItem value="flutterwave">Flutterwave</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><Label>Paystack Public</Label><Input value={payment.paystack_public} onChange={(event) => setPayment((prev) => ({ ...prev, paystack_public: event.target.value }))} className="mt-1" /></div>
<div><Label>Paystack Secret</Label><Input type="password" value={payment.paystack_secret} onChange={(event) => setPayment((prev) => ({ ...prev, paystack_secret: event.target.value }))} className="mt-1" /></div>
<div><Label>Flutterwave Public</Label><Input value={payment.flutterwave_public} onChange={(event) => setPayment((prev) => ({ ...prev, flutterwave_public: event.target.value }))} className="mt-1" /></div>
<div><Label>Flutterwave Secret</Label><Input type="password" value={payment.flutterwave_secret} onChange={(event) => setPayment((prev) => ({ ...prev, flutterwave_secret: event.target.value }))} className="mt-1" /></div>
<div><Label>Flutterwave Webhook Hash</Label><Input value={payment.flutterwave_webhook_hash} onChange={(event) => setPayment((prev) => ({ ...prev, flutterwave_webhook_hash: event.target.value }))} className="mt-1" /></div>
<div><Label>Callback URL</Label><Input value={payment.callback_url} onChange={(event) => setPayment((prev) => ({ ...prev, callback_url: event.target.value }))} className="mt-1" /></div>
</div>
<Button onClick={() => saveSection("payment", () => appClient.settings.savePayment(compactPayload(payment)))} disabled={saving.payment}>
{saving.payment ? "Saving..." : "Save Payment"}
</Button>
</div>
</TabsContent>
<TabsContent value="backup">
<div className="surface-card p-6 space-y-5">
<h3 className="text-sm font-semibold text-foreground">Backup Platform Policy</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>Default Source</Label>
<Select value={backup.default_source} onValueChange={(value) => setBackup((prev) => ({ ...prev, default_source: value }))}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="pbs">PBS</SelectItem>
<SelectItem value="remote">Remote</SelectItem>
</SelectContent>
</Select>
</div>
<div><Label>Default Storage</Label><Input value={backup.default_storage} onChange={(event) => setBackup((prev) => ({ ...prev, default_storage: event.target.value }))} className="mt-1" /></div>
<div><Label>Max Restore File Count</Label><Input type="number" min="1" value={backup.max_restore_file_count} onChange={(event) => setBackup((prev) => ({ ...prev, max_restore_file_count: Number(event.target.value || 100) }))} className="mt-1" /></div>
<div className="flex items-center gap-2 pt-7"><Switch checked={backup.pbs_enabled} onCheckedChange={(value) => setBackup((prev) => ({ ...prev, pbs_enabled: value }))} /><Label className="text-sm text-muted-foreground">Enable PBS</Label></div>
<div><Label>PBS Host</Label><Input value={backup.pbs_host} onChange={(event) => setBackup((prev) => ({ ...prev, pbs_host: event.target.value }))} className="mt-1" /></div>
<div><Label>PBS Datastore</Label><Input value={backup.pbs_datastore} onChange={(event) => setBackup((prev) => ({ ...prev, pbs_datastore: event.target.value }))} className="mt-1" /></div>
<div><Label>PBS Namespace</Label><Input value={backup.pbs_namespace} onChange={(event) => setBackup((prev) => ({ ...prev, pbs_namespace: event.target.value }))} className="mt-1" /></div>
<div className="flex items-center gap-2 pt-7"><Switch checked={backup.pbs_verify_ssl} onCheckedChange={(value) => setBackup((prev) => ({ ...prev, pbs_verify_ssl: value }))} /><Label className="text-sm text-muted-foreground">Verify PBS SSL</Label></div>
</div>
<Button onClick={() => saveSection("backup", () => appClient.settings.saveBackup(compactPayload(backup)))} disabled={saving.backup}>
{saving.backup ? "Saving..." : "Save Backup"}
</Button>
</div>
</TabsContent>
<TabsContent value="console-proxy">
<div className="surface-card p-6 space-y-5">
<h3 className="text-sm font-semibold text-foreground">Console Proxy Routing</h3>
<div>
<Label>Mode</Label>
<Select value={consoleProxy.mode} onValueChange={(value) => setConsoleProxy((prev) => ({ ...prev, mode: value }))}>
<SelectTrigger className="mt-1 max-w-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="cluster">Cluster</SelectItem>
<SelectItem value="per_node">Per Node</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div><Label>noVNC URL</Label><Input value={consoleProxy.cluster?.novnc || ""} onChange={(event) => setConsoleProxy((prev) => ({ ...prev, cluster: { ...(prev.cluster || {}), novnc: event.target.value } }))} className="mt-1 font-mono text-xs" /></div>
<div><Label>SPICE URL</Label><Input value={consoleProxy.cluster?.spice || ""} onChange={(event) => setConsoleProxy((prev) => ({ ...prev, cluster: { ...(prev.cluster || {}), spice: event.target.value } }))} className="mt-1 font-mono text-xs" /></div>
<div><Label>XTerm URL</Label><Input value={consoleProxy.cluster?.xterm || ""} onChange={(event) => setConsoleProxy((prev) => ({ ...prev, cluster: { ...(prev.cluster || {}), xterm: event.target.value } }))} className="mt-1 font-mono text-xs" /></div>
</div>
<Button onClick={() => saveSection("consoleProxy", () => appClient.settings.saveConsoleProxy(compactPayload(consoleProxy)))} disabled={saving.consoleProxy}>
{saving.consoleProxy ? "Saving..." : "Save Console Proxy"}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
);
}

121
src/pages/Tenants.jsx Normal file
View File

@@ -0,0 +1,121 @@
import { useState, useEffect } from "react";
import { appClient } from "@/api/appClient";
import { Plus, Building2, Search } from "lucide-react";
import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import { motion } from "framer-motion";
export default function Tenants() {
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", owner_email: "", plan: "starter", currency: "NGN", payment_provider: "paystack", vm_limit: 5, cpu_limit: 16, ram_limit_mb: 16384, disk_limit_gb: 500 });
const { toast } = useToast();
useEffect(() => { loadData(); }, []);
const loadData = async () => { setTenants(await appClient.entities.Tenant.list("-created_date")); setLoading(false); };
const handleCreate = async () => {
if (!form.name || !form.owner_email) return;
setCreating(true);
await appClient.entities.Tenant.create({ ...form, status: "active", slug: form.name.toLowerCase().replace(/\s+/g, "-"), balance: 0, member_emails: [] });
await loadData();
setShowCreate(false);
setForm({ name: "", owner_email: "", plan: "starter", currency: "NGN", payment_provider: "paystack", vm_limit: 5, cpu_limit: 16, ram_limit_mb: 16384, disk_limit_gb: 500 });
setCreating(false);
toast({ title: "Tenant Created", description: form.name });
};
const handleDelete = async (t) => {
await appClient.entities.Tenant.delete(t.id);
await loadData();
toast({ title: "Tenant Deleted", description: t.name, variant: "destructive" });
};
const filtered = tenants.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || (t.owner_email || "").toLowerCase().includes(search.toLowerCase()));
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-4 border-muted border-t-primary rounded-full animate-spin" /></div>;
return (
<div className="space-y-6">
<PageHeader title="Tenants" description={`${tenants.length} organizations`}>
<Button onClick={() => setShowCreate(true)} className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"><Plus className="w-4 h-4" /> New Tenant</Button>
</PageHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input placeholder="Search tenants..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9 bg-card border-border max-w-sm" />
</div>
{filtered.length === 0 ? (
<EmptyState icon={Building2} title="No Tenants" description="Create your first tenant organization."
action={<Button onClick={() => setShowCreate(true)} variant="outline" className="gap-2"><Plus className="w-4 h-4" /> New Tenant</Button>} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((t, i) => (
<motion.div key={t.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.04 }}
className="surface-card p-5 hover:border-primary/20 transition-all group">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/30 flex items-center justify-center">
<Building2 className="w-5 h-5 text-accent" />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">{t.name}</h3>
<p className="text-[11px] text-muted-foreground">{t.owner_email}</p>
</div>
</div>
<StatusBadge status={t.status} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground py-3 border-t border-border">
<div>Plan: <span className="text-foreground font-medium capitalize">{t.plan}</span></div>
<div>VMs: <span className="text-foreground font-medium">{t.vm_limit || 0} max</span></div>
<div>Balance: <span className="text-foreground font-medium">{t.currency} {(t.balance || 0).toLocaleString()}</span></div>
<div>Payment: <span className="text-foreground font-medium capitalize">{t.payment_provider || "—"}</span></div>
</div>
<div className="flex justify-end pt-2">
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50 text-xs opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleDelete(t)}>Delete</Button>
</div>
</motion.div>
))}
</div>
)}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent className="bg-card border-border max-w-lg">
<DialogHeader><DialogTitle>Create Tenant</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div><Label>Organization Name</Label><Input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} className="bg-muted border-border mt-1" /></div>
<div><Label>Owner Email</Label><Input type="email" value={form.owner_email} onChange={e => setForm({ ...form, owner_email: e.target.value })} className="bg-muted border-border mt-1" /></div>
</div>
<div className="grid grid-cols-3 gap-3">
<div><Label>Plan</Label><Select value={form.plan} onValueChange={v => setForm({ ...form, plan: v })}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="starter">Starter</SelectItem><SelectItem value="professional">Professional</SelectItem><SelectItem value="enterprise">Enterprise</SelectItem><SelectItem value="custom">Custom</SelectItem></SelectContent></Select></div>
<div><Label>Currency</Label><Select value={form.currency} onValueChange={v => setForm({ ...form, currency: v })}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="NGN">NGN</SelectItem><SelectItem value="USD">USD</SelectItem><SelectItem value="GHS">GHS</SelectItem><SelectItem value="KES">KES</SelectItem><SelectItem value="ZAR">ZAR</SelectItem></SelectContent></Select></div>
<div><Label>Payment</Label><Select value={form.payment_provider} onValueChange={v => setForm({ ...form, payment_provider: v })}><SelectTrigger className="bg-muted border-border mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="paystack">Paystack</SelectItem><SelectItem value="flutterwave">Flutterwave</SelectItem><SelectItem value="manual">Manual</SelectItem></SelectContent></Select></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>VM Limit</Label><Input type="number" value={form.vm_limit} onChange={e => setForm({ ...form, vm_limit: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div>
<div><Label>Disk Limit (GB)</Label><Input type="number" value={form.disk_limit_gb} onChange={e => setForm({ ...form, disk_limit_gb: Number(e.target.value) })} className="bg-muted border-border mt-1" /></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={creating || !form.name || !form.owner_email} className="bg-primary text-primary-foreground">{creating ? "Creating..." : "Create Tenant"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,436 @@
import { useEffect, useMemo, useState } from "react";
import { appClient } from "@/api/appClient";
import {
Filter,
LineChart as LineChartIcon,
Play,
Plus,
RotateCcw,
Search,
Server,
Square,
Trash2
} from "lucide-react";
import { motion } from "framer-motion";
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import PageHeader from "../components/shared/PageHeader";
import StatusBadge from "../components/shared/StatusBadge";
import EmptyState from "../components/shared/EmptyState";
import ResourceBar from "../components/shared/ResourceBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
const osTemplates = [
"Ubuntu 22.04 LTS",
"Ubuntu 24.04 LTS",
"Debian 12",
"CentOS Stream 9",
"Rocky Linux 9",
"AlmaLinux 9",
"Fedora 39",
"Windows Server 2022"
];
const graphTimeframes = ["hour", "day", "week", "month", "year"];
const chartTooltipStyle = {
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "12px",
fontSize: "12px",
color: "hsl(var(--foreground))"
};
function formatGraphLabel(value) {
if (!value) return "";
const date = new Date(value);
return `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`;
}
export default function VirtualMachines() {
const [vms, setVms] = useState([]);
const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [showGraphs, setShowGraphs] = useState(false);
const [graphVm, setGraphVm] = useState(null);
const [graphTimeframe, setGraphTimeframe] = useState("day");
const [graphLoading, setGraphLoading] = useState(false);
const [graphData, setGraphData] = useState(null);
const [form, setForm] = useState({ name: "", type: "qemu", node: "", os_template: "", cpu_cores: 2, ram_mb: 2048, disk_gb: 40 });
const { toast } = useToast();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
const [vmData, nodeData] = await Promise.all([
appClient.entities.VirtualMachine.list("-created_date"),
appClient.entities.ProxmoxNode.list()
]);
setVms(vmData);
setNodes(nodeData);
setLoading(false);
};
const runningCount = useMemo(() => vms.filter((vm) => vm.status === "running").length, [vms]);
const handleCreate = async () => {
if (!form.name || !form.node) return;
setCreating(true);
await appClient.entities.VirtualMachine.create({
...form,
status: "stopped",
vmid: Math.floor(100 + Math.random() * 9900),
cpu_usage: 0,
ram_usage: 0,
disk_usage: 0,
network_in: 0,
network_out: 0,
uptime_seconds: 0
});
await loadData();
setShowCreate(false);
setForm({ name: "", type: "qemu", node: "", os_template: "", cpu_cores: 2, ram_mb: 2048, disk_gb: 40 });
setCreating(false);
toast({ title: "VM Created", description: `${form.name} has been created successfully.` });
};
const handleAction = async (vm, action) => {
const statusMap = { start: "running", stop: "stopped", restart: "running" };
await appClient.entities.VirtualMachine.update(vm.id, { status: statusMap[action] });
await loadData();
toast({ title: `VM ${action === "start" ? "Started" : action === "stop" ? "Stopped" : "Restarted"}`, description: vm.name });
};
const handleDelete = async (vm) => {
await appClient.entities.VirtualMachine.delete(vm.id);
await loadData();
toast({ title: "VM Deleted", description: vm.name, variant: "destructive" });
};
const loadVmGraphs = async (vmId, timeframe = graphTimeframe) => {
setGraphLoading(true);
try {
const payload = await appClient.proxmox.vmUsageGraphs(vmId, timeframe);
setGraphData(payload);
} catch (error) {
toast({
title: "Graph Load Failed",
description: error?.message || "Unable to fetch VM usage graph",
variant: "destructive"
});
setGraphData(null);
} finally {
setGraphLoading(false);
}
};
const openGraphs = async (vm) => {
setGraphVm(vm);
setGraphTimeframe("day");
setShowGraphs(true);
await loadVmGraphs(vm.id, "day");
};
const filtered = vms.filter((vm) => {
const matchSearch = vm.name.toLowerCase().includes(search.toLowerCase()) || (vm.ip_address || "").includes(search);
const matchStatus = statusFilter === "all" || vm.status === statusFilter;
return matchSearch && matchStatus;
});
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="Virtual Machines" description={`${vms.length} total machines`}>
<Button onClick={() => setShowCreate(true)} className="gap-2">
<Plus className="h-4 w-4" />
Create VM
</Button>
</PageHeader>
<div className="flex flex-wrap items-center gap-2">
<span className="kpi-chip">Running: {runningCount}</span>
<span className="kpi-chip">Stopped: {Math.max(vms.length - runningCount, 0)}</span>
<span className="kpi-chip">Displayed: {filtered.length}</span>
</div>
<div className="surface-card p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search VMs by name or IP"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[170px]">
<Filter className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="stopped">Stopped</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="error">Error</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{filtered.length === 0 ? (
<EmptyState
icon={Server}
title="No Virtual Machines"
description="Create your first VM to get started."
action={
<Button onClick={() => setShowCreate(true)} variant="outline" className="gap-2">
<Plus className="h-4 w-4" />
Create VM
</Button>
}
/>
) : (
<div className="data-grid-wrap">
<table className="data-grid-table">
<thead>
<tr>
<th>Machine</th>
<th>Status</th>
<th>Node</th>
<th>Resources</th>
<th>Utilization</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((vm) => (
<motion.tr key={vm.id} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }}>
<td>
<div>
<p className="font-semibold text-foreground">{vm.name}</p>
<p className="font-mono text-[11px] text-muted-foreground">VMID {vm.vmid} - {(vm.type || "").toUpperCase()} - {vm.ip_address || "No IP"}</p>
</div>
</td>
<td><StatusBadge status={vm.status} /></td>
<td className="text-sm text-muted-foreground">{vm.node || "Unassigned"}</td>
<td className="text-xs text-muted-foreground">
<div className="space-y-0.5">
<p>{vm.cpu_cores || 0} vCPU</p>
<p>{vm.ram_mb || 0} MB RAM</p>
<p>{vm.disk_gb || 0} GB Disk</p>
</div>
</td>
<td>
<div className="w-[160px] space-y-1.5 sm:w-[210px]">
<ResourceBar label="CPU" percentage={vm.cpu_usage || 0} />
<ResourceBar label="RAM" percentage={vm.ram_usage || 0} />
<ResourceBar label="Disk" percentage={vm.disk_usage || 0} />
</div>
</td>
<td>
<div className="flex justify-end gap-1.5">
{vm.status !== "running" && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-emerald-600 hover:bg-emerald-50" onClick={() => handleAction(vm, "start")}>
<Play className="h-3.5 w-3.5" />
</Button>
)}
{vm.status === "running" && (
<>
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-600 hover:bg-amber-50" onClick={() => handleAction(vm, "stop")}>
<Square className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-600 hover:bg-blue-50" onClick={() => handleAction(vm, "restart")}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-sky-600 hover:bg-sky-50" onClick={() => openGraphs(vm)}>
<LineChartIcon className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-rose-600 hover:bg-rose-50" onClick={() => handleDelete(vm)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
)}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Virtual Machine</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>VM Name</Label>
<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="my-server-01" className="mt-1" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label>Type</Label>
<Select value={form.type} onValueChange={(value) => setForm({ ...form, type: value })}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="qemu">QEMU/KVM</SelectItem>
<SelectItem value="lxc">LXC Container</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Node</Label>
<Select value={form.node} onValueChange={(value) => setForm({ ...form, node: value })}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Select node" /></SelectTrigger>
<SelectContent>
{nodes.map((node) => <SelectItem key={node.id} value={node.name}>{node.name}</SelectItem>)}
{nodes.length === 0 && <SelectItem value="node-1">node-1 (default)</SelectItem>}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>OS Template</Label>
<Select value={form.os_template} onValueChange={(value) => setForm({ ...form, os_template: value })}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Select OS" /></SelectTrigger>
<SelectContent>
{osTemplates.map((os) => <SelectItem key={os} value={os}>{os}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<Label>CPU Cores</Label>
<Input type="number" value={form.cpu_cores} onChange={(event) => setForm({ ...form, cpu_cores: Number(event.target.value) })} className="mt-1" min={1} max={64} />
</div>
<div>
<Label>RAM (MB)</Label>
<Input type="number" value={form.ram_mb} onChange={(event) => setForm({ ...form, ram_mb: Number(event.target.value) })} className="mt-1" min={256} step={256} />
</div>
<div>
<Label>Disk (GB)</Label>
<Input type="number" value={form.disk_gb} onChange={(event) => setForm({ ...form, disk_gb: Number(event.target.value) })} className="mt-1" min={5} step={5} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={creating || !form.name || !form.node}>
{creating ? "Creating..." : "Create VM"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showGraphs} onOpenChange={setShowGraphs}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>Usage Graphs - {graphVm?.name || "VM"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="panel-head">
<div>
<p className="panel-title">Timeseries Usage</p>
<p className="panel-subtitle">Source: {graphData?.source || "n/a"} | Node: {graphData?.node || graphVm?.node || "n/a"}</p>
</div>
<Select
value={graphTimeframe}
onValueChange={async (value) => {
setGraphTimeframe(value);
if (graphVm?.id) await loadVmGraphs(graphVm.id, value);
}}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{graphTimeframes.map((timeframe) => (
<SelectItem key={timeframe} value={timeframe}>{timeframe.toUpperCase()}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{graphLoading ? (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
) : graphData?.points?.length > 0 ? (
<div className="space-y-4">
<div className="chart-panel">
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={graphData.points}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="timestamp" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} tickFormatter={formatGraphLabel} />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} domain={[0, 100]} />
<Tooltip contentStyle={chartTooltipStyle} labelFormatter={formatGraphLabel} formatter={(value) => `${Number(value).toFixed(2)}%`} />
<Legend />
<Line type="monotone" dataKey="cpu_pct" stroke="#ea580c" dot={false} strokeWidth={2} name="CPU %" />
<Line type="monotone" dataKey="ram_pct" stroke="#0ea5e9" dot={false} strokeWidth={2} name="RAM %" />
<Line type="monotone" dataKey="disk_usage_pct" stroke="#16a34a" dot={false} strokeWidth={2} name="Disk %" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="chart-panel">
<div className="h-52 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={graphData.points}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="timestamp" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} tickFormatter={formatGraphLabel} />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} />
<Tooltip contentStyle={chartTooltipStyle} labelFormatter={formatGraphLabel} formatter={(value) => `${Number(value).toFixed(3)} MB/s`} />
<Legend />
<Line type="monotone" dataKey="network_in_mbps" stroke="#4338ca" dot={false} strokeWidth={2} name="Net In MB/s" />
<Line type="monotone" dataKey="network_out_mbps" stroke="#be123c" dot={false} strokeWidth={2} name="Net Out MB/s" />
<Line type="monotone" dataKey="disk_io_read_mbps" stroke="#0f766e" dot={false} strokeWidth={2} name="Disk Read MB/s" />
<Line type="monotone" dataKey="disk_io_write_mbps" stroke="#b45309" dot={false} strokeWidth={2} name="Disk Write MB/s" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs md:grid-cols-5">
<div className="surface-card-quiet p-2">Avg CPU: {graphData.summary?.avg_cpu_pct ?? 0}%</div>
<div className="surface-card-quiet p-2">Peak CPU: {graphData.summary?.peak_cpu_pct ?? 0}%</div>
<div className="surface-card-quiet p-2">Peak Net In: {graphData.summary?.peak_network_in_mbps ?? 0} MB/s</div>
<div className="surface-card-quiet p-2">Peak Disk Read: {graphData.summary?.peak_disk_io_read_mbps ?? 0} MB/s</div>
<div className="surface-card-quiet p-2">Peak Disk Write: {graphData.summary?.peak_disk_io_write_mbps ?? 0} MB/s</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No graph samples available for this VM and timeframe.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowGraphs(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

3
src/util/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export function createPageUrl(pageName: string) {
return "/" + pageName.replace(/ /g, "-");
}

3
src/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export function createPageUrl(pageName: string) {
return '/' + pageName.replace(/ /g, '-');
}