chore: initialize repository with deployment baseline
This commit is contained in:
83
src/App.jsx
Normal file
83
src/App.jsx
Normal 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
1129
src/api/appClient.js
Normal file
File diff suppressed because it is too large
Load Diff
34
src/components/ProtectedRoute.jsx
Normal file
34
src/components/ProtectedRoute.jsx
Normal 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 />;
|
||||
}
|
||||
34
src/components/UserNotRegisteredError.jsx
Normal file
34
src/components/UserNotRegisteredError.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/layout/AppLayout.jsx
Normal file
54
src/components/layout/AppLayout.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/layout/Sidebar.jsx
Normal file
164
src/components/layout/Sidebar.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
src/components/layout/nav-config.js
Normal file
54
src/components/layout/nav-config.js
Normal 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;
|
||||
}
|
||||
14
src/components/shared/EmptyState.jsx
Normal file
14
src/components/shared/EmptyState.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/shared/PageHeader.jsx
Normal file
12
src/components/shared/PageHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/shared/ResourceBar.jsx
Normal file
25
src/components/shared/ResourceBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/shared/StatCard.jsx
Normal file
28
src/components/shared/StatCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/shared/StatusBadge.jsx
Normal file
35
src/components/shared/StatusBadge.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/UserNotRegisteredError.jsx
Normal file
19
src/components/ui/UserNotRegisteredError.jsx
Normal 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
|
||||
}
|
||||
97
src/components/ui/accordion.jsx
Normal file
97
src/components/ui/accordion.jsx
Normal 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,
|
||||
}
|
||||
47
src/components/ui/alert-dialog.jsx
Normal file
47
src/components/ui/alert-dialog.jsx
Normal 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 }
|
||||
5
src/components/ui/alert.jsx
Normal file
5
src/components/ui/alert.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
35
src/components/ui/aspect-ratio.jsx
Normal file
35
src/components/ui/aspect-ratio.jsx
Normal 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 }
|
||||
34
src/components/ui/avatar.jsx
Normal file
34
src/components/ui/avatar.jsx
Normal 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 }
|
||||
13
src/components/ui/badge.jsx
Normal file
13
src/components/ui/badge.jsx
Normal 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 };
|
||||
48
src/components/ui/breadcrumb.jsx
Normal file
48
src/components/ui/breadcrumb.jsx
Normal 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 }
|
||||
37
src/components/ui/button.jsx
Normal file
37
src/components/ui/button.jsx
Normal 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 };
|
||||
50
src/components/ui/calendar.jsx
Normal file
50
src/components/ui/calendar.jsx
Normal 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
193
src/components/ui/card.jsx
Normal 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 };
|
||||
309
src/components/ui/carousel.jsx
Normal file
309
src/components/ui/carousel.jsx
Normal 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,
|
||||
}
|
||||
22
src/components/ui/chart.jsx
Normal file
22
src/components/ui/chart.jsx
Normal 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 }
|
||||
11
src/components/ui/checkbox.jsx
Normal file
11
src/components/ui/checkbox.jsx
Normal 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 }
|
||||
116
src/components/ui/collapsible.jsx
Normal file
116
src/components/ui/collapsible.jsx
Normal 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,
|
||||
}
|
||||
156
src/components/ui/command.jsx
Normal file
156
src/components/ui/command.jsx
Normal 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,
|
||||
}
|
||||
156
src/components/ui/context-menu.jsx
Normal file
156
src/components/ui/context-menu.jsx
Normal 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,
|
||||
}
|
||||
42
src/components/ui/dialog.jsx
Normal file
42
src/components/ui/dialog.jsx
Normal 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 };
|
||||
156
src/components/ui/drawer.jsx
Normal file
156
src/components/ui/drawer.jsx
Normal 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,
|
||||
}
|
||||
134
src/components/ui/dropdown-menu.jsx
Normal file
134
src/components/ui/dropdown-menu.jsx
Normal 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,
|
||||
}
|
||||
25
src/components/ui/form.jsx
Normal file
25
src/components/ui/form.jsx
Normal 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 }
|
||||
53
src/components/ui/hover-card.jsx
Normal file
53
src/components/ui/hover-card.jsx
Normal 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 }
|
||||
19
src/components/ui/input-otp.jsx
Normal file
19
src/components/ui/input-otp.jsx
Normal 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 }
|
||||
20
src/components/ui/input.jsx
Normal file
20
src/components/ui/input.jsx
Normal 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 };
|
||||
10
src/components/ui/label.jsx
Normal file
10
src/components/ui/label.jsx
Normal 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 };
|
||||
104
src/components/ui/menubar.jsx
Normal file
104
src/components/ui/menubar.jsx
Normal 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,
|
||||
}
|
||||
100
src/components/ui/navigation-menu.jsx
Normal file
100
src/components/ui/navigation-menu.jsx
Normal 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,
|
||||
}
|
||||
27
src/components/ui/pagination.jsx
Normal file
27
src/components/ui/pagination.jsx
Normal 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 }
|
||||
23
src/components/ui/popover.jsx
Normal file
23
src/components/ui/popover.jsx
Normal 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 }
|
||||
29
src/components/ui/progress.jsx
Normal file
29
src/components/ui/progress.jsx
Normal 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 }
|
||||
42
src/components/ui/radio-group.jsx
Normal file
42
src/components/ui/radio-group.jsx
Normal 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 }
|
||||
38
src/components/ui/resizable.jsx
Normal file
38
src/components/ui/resizable.jsx
Normal 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 }
|
||||
121
src/components/ui/scroll-area.jsx
Normal file
121
src/components/ui/scroll-area.jsx
Normal 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,
|
||||
}
|
||||
89
src/components/ui/select.jsx
Normal file
89
src/components/ui/select.jsx
Normal 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 };
|
||||
109
src/components/ui/separator.jsx
Normal file
109
src/components/ui/separator.jsx
Normal 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
626
src/components/ui/sheet.jsx
Normal 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,
|
||||
}
|
||||
14
src/components/ui/sidebar.jsx
Normal file
14
src/components/ui/sidebar.jsx
Normal 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 }
|
||||
21
src/components/ui/skeleton.jsx
Normal file
21
src/components/ui/skeleton.jsx
Normal 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 }
|
||||
29
src/components/ui/slider.jsx
Normal file
29
src/components/ui/slider.jsx
Normal 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 }
|
||||
22
src/components/ui/sonner.jsx
Normal file
22
src/components/ui/sonner.jsx
Normal 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 }
|
||||
31
src/components/ui/switch.jsx
Normal file
31
src/components/ui/switch.jsx
Normal 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 };
|
||||
41
src/components/ui/table.jsx
Normal file
41
src/components/ui/table.jsx
Normal 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 }
|
||||
60
src/components/ui/tabs.jsx
Normal file
60
src/components/ui/tabs.jsx
Normal 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 };
|
||||
105
src/components/ui/textarea.jsx
Normal file
105
src/components/ui/textarea.jsx
Normal 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,
|
||||
};
|
||||
|
||||
1
src/components/ui/toast.jsx
Normal file
1
src/components/ui/toast.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
23
src/components/ui/toaster.jsx
Normal file
23
src/components/ui/toaster.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/toggle-group.jsx
Normal file
38
src/components/ui/toggle-group.jsx
Normal 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 }
|
||||
28
src/components/ui/toggle.jsx
Normal file
28
src/components/ui/toggle.jsx
Normal 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 }
|
||||
164
src/components/ui/tooltip.jsx
Normal file
164
src/components/ui/tooltip.jsx
Normal 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 };
|
||||
47
src/components/ui/use-toast.jsx
Normal file
47
src/components/ui/use-toast.jsx
Normal 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
19
src/hooks/use-mobile.jsx
Normal 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
201
src/index.css
Normal 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
77
src/lib/AuthContext.jsx
Normal 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
53
src/lib/PageNotFound.jsx
Normal 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
54
src/lib/app-params.js
Normal 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
11
src/lib/query-client.js
Normal 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
9
src/lib/utils.js
Normal 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
8
src/main.jsx
Normal 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
88
src/pages/AuditLogs.jsx
Normal 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
231
src/pages/Backups.jsx
Normal 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
323
src/pages/Billing.jsx
Normal 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
1101
src/pages/ClientArea.jsx
Normal file
File diff suppressed because it is too large
Load Diff
544
src/pages/Dashboard.jsx
Normal file
544
src/pages/Dashboard.jsx
Normal 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
117
src/pages/Login.jsx
Normal 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
681
src/pages/Monitoring.jsx
Normal 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
249
src/pages/NetworkIpam.jsx
Normal 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
258
src/pages/Nodes.jsx
Normal 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
355
src/pages/Operations.jsx
Normal 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
611
src/pages/Provisioning.jsx
Normal 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
170
src/pages/RBAC.jsx
Normal 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
545
src/pages/Security.jsx
Normal 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
452
src/pages/Settings.jsx
Normal 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
121
src/pages/Tenants.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
436
src/pages/VirtualMachines.jsx
Normal file
436
src/pages/VirtualMachines.jsx
Normal 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
3
src/util/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function createPageUrl(pageName: string) {
|
||||
return "/" + pageName.replace(/ /g, "-");
|
||||
}
|
||||
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function createPageUrl(pageName: string) {
|
||||
return '/' + pageName.replace(/ /g, '-');
|
||||
}
|
||||
Reference in New Issue
Block a user