feat: initialize Infra Manager with React, Tailwind CSS, and Inertia.js

- Added main application entry point (app.jsx) and bootstrap file for React setup.
- Created Wiki index page (Index.jsx) for documentation and file management.
- Implemented utility functions for formatting and status handling in utils.js.
- Set up Blade template (app.blade.php) for Inertia.js integration.
- Defined API routes for metrics and terminal access in api.php.
- Established console commands for scheduled tasks in console.php.
- Created web routes for various application features including authentication, hypervisors, containers, cloud accounts, security, backups, and wiki management in web.php.
- Developed setup scripts for Windows (setup.ps1) and Linux (setup.sh) environments.
- Configured Tailwind CSS for styling with custom themes and animations in tailwind.config.js.
- Set up Vite for asset bundling and development server in vite.config.js.
This commit is contained in:
I-Fun
2026-04-07 20:29:32 +07:00
commit c649f055d8
126 changed files with 8122 additions and 0 deletions

112
resources/css/app.css Normal file
View File

@@ -0,0 +1,112 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 184 100% 50%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 184 100% 50%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer components {
/* Aceternity-style glassmorphism card */
.glass-card {
@apply relative overflow-hidden rounded-xl border border-white/10 bg-white/5 backdrop-blur-xl;
}
/* Gradient border effect */
.gradient-border {
@apply relative;
background: linear-gradient(var(--background), var(--background)) padding-box,
linear-gradient(135deg, #00d4ff, #9d4edd, #00ff88) border-box;
border: 1px solid transparent;
}
/* Status badge variants */
.status-online { @apply bg-green-500/20 text-green-400 border border-green-500/30; }
.status-offline { @apply bg-red-500/20 text-red-400 border border-red-500/30; }
.status-warning { @apply bg-yellow-500/20 text-yellow-400 border border-yellow-500/30; }
.status-unknown { @apply bg-gray-500/20 text-gray-400 border border-gray-500/30; }
.status-running { @apply bg-cyan-500/20 text-cyan-400 border border-cyan-500/30; }
.status-stopped { @apply bg-slate-500/20 text-slate-400 border border-slate-500/30; }
/* Neon glow effects */
.glow-cyan { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }
.glow-green { box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); }
.glow-purple { box-shadow: 0 0 20px rgba(157, 78, 221, 0.3); }
/* Custom scrollbar */
.custom-scroll::-webkit-scrollbar {
@apply w-1.5;
}
.custom-scroll::-webkit-scrollbar-track {
@apply bg-transparent;
}
.custom-scroll::-webkit-scrollbar-thumb {
@apply rounded-full bg-white/10;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
@apply bg-white/20;
}
/* Terminal style */
.terminal {
@apply font-mono text-sm bg-black/60 text-green-400 p-4 rounded-lg;
}
}
/* Grid background pattern */
.bg-grid-pattern {
background-image:
linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
background-size: 50px 50px;
}
/* Aurora background */
.aurora-bg {
background: linear-gradient(
125deg,
rgba(0, 212, 255, 0.05) 0%,
rgba(157, 78, 221, 0.05) 33%,
rgba(0, 255, 136, 0.05) 66%,
rgba(0, 212, 255, 0.05) 100%
);
background-size: 400% 400%;
animation: aurora 15s ease infinite;
}
@keyframes aurora {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}

View File

@@ -0,0 +1,42 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function Button({ children, variant = 'primary', size = 'md', className, loading, icon: Icon, ...props }) {
const variants = {
primary: 'bg-cyan-500 hover:bg-cyan-400 text-black font-semibold shadow-[0_0_20px_rgba(0,212,255,0.3)] hover:shadow-[0_0_30px_rgba(0,212,255,0.5)]',
secondary: 'bg-white/10 hover:bg-white/15 text-white border border-white/20',
danger: 'bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30',
ghost: 'hover:bg-white/5 text-white/70 hover:text-white',
outline: 'border border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10',
};
const sizes = {
sm: 'h-7 px-3 text-xs gap-1.5',
md: 'h-9 px-4 text-sm gap-2',
lg: 'h-11 px-6 text-base gap-2',
icon: 'h-9 w-9 p-0',
};
return (
<motion.button
whileTap={{ scale: 0.97 }}
className={cn(
'inline-flex items-center justify-center rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
variants[variant],
sizes[size],
className
)}
disabled={loading || props.disabled}
{...props}
>
{loading ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : Icon ? (
<Icon className="w-4 h-4" />
) : null}
{size !== 'icon' && children}
</motion.button>
);
}

View File

@@ -0,0 +1,137 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronUp, ChevronDown, Search, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export function DataTable({ columns, data, onRowClick, emptyMessage = 'No data found', searchable = true, className }) {
const [search, setSearch] = useState('');
const [sortCol, setSortCol] = useState(null);
const [sortDir, setSortDir] = useState('asc');
const [page, setPage] = useState(1);
const perPage = 15;
const filtered = search
? data.filter(row => columns.some(col => {
const val = col.accessor ? row[col.accessor] : '';
return String(val ?? '').toLowerCase().includes(search.toLowerCase());
}))
: data;
const sorted = sortCol
? [...filtered].sort((a, b) => {
const va = a[sortCol] ?? '';
const vb = b[sortCol] ?? '';
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
})
: filtered;
const totalPages = Math.ceil(sorted.length / perPage);
const paginated = sorted.slice((page - 1) * perPage, page * perPage);
const handleSort = (accessor) => {
if (sortCol === accessor) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortCol(accessor);
setSortDir('asc');
}
setPage(1);
};
return (
<div className={cn('space-y-3', className)}>
{searchable && (
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-cyan-500/50 focus:bg-white/[0.07] transition-all"
/>
</div>
)}
<div className="overflow-x-auto rounded-xl border border-white/10">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/[0.02]">
{columns.map(col => (
<th key={col.key ?? col.accessor}
className={cn(
'text-left px-4 py-3 text-xs font-medium text-white/50 uppercase tracking-wider whitespace-nowrap',
col.accessor && 'cursor-pointer hover:text-white/70 select-none'
)}
onClick={() => col.accessor && handleSort(col.accessor)}
>
<div className="flex items-center gap-1">
{col.header}
{col.accessor && sortCol === col.accessor && (
sortDir === 'asc'
? <ChevronUp className="w-3 h-3 text-cyan-400" />
: <ChevronDown className="w-3 h-3 text-cyan-400" />
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
<AnimatePresence mode="wait">
{paginated.length === 0 ? (
<tr>
<td colSpan={columns.length} className="text-center py-12 text-white/30">
{emptyMessage}
</td>
</tr>
) : (
paginated.map((row, i) => (
<motion.tr
key={row.id ?? i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
onClick={() => onRowClick?.(row)}
className={cn(
'border-b border-white/[0.05] last:border-0 transition-colors',
onRowClick && 'cursor-pointer hover:bg-white/[0.03]'
)}
>
{columns.map(col => (
<td key={col.key ?? col.accessor} className="px-4 py-3 text-white/80">
{col.render ? col.render(row) : row[col.accessor]}
</td>
))}
</motion.tr>
))
)}
</AnimatePresence>
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between text-xs text-white/40">
<span>{filtered.length} results</span>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={page === 1} className="p-1 hover:text-white disabled:opacity-30">
<ChevronsLeft className="w-4 h-4" />
</button>
<button onClick={() => setPage(p => p - 1)} disabled={page === 1} className="p-1 hover:text-white disabled:opacity-30">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-3 text-white">Page {page} of {totalPages}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page === totalPages} className="p-1 hover:text-white disabled:opacity-30">
<ChevronRight className="w-4 h-4" />
</button>
<button onClick={() => setPage(totalPages)} disabled={page === totalPages} className="p-1 hover:text-white disabled:opacity-30">
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function GlassCard({ children, className, hover = true, glow, ...props }) {
return (
<motion.div
whileHover={hover ? { y: -2, scale: 1.005 } : undefined}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className={cn(
'relative overflow-hidden rounded-xl',
'border border-white/10 bg-white/[0.03] backdrop-blur-xl',
hover && 'cursor-pointer',
glow === 'cyan' && 'hover:border-cyan-500/40 hover:shadow-[0_0_30px_rgba(0,212,255,0.1)]',
glow === 'green' && 'hover:border-green-500/40 hover:shadow-[0_0_30px_rgba(0,255,136,0.1)]',
glow === 'purple' && 'hover:border-purple-500/40 hover:shadow-[0_0_30px_rgba(157,78,221,0.1)]',
className
)}
{...props}
>
{/* Inner gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.02] to-transparent pointer-events-none" />
<div className="relative z-10">{children}</div>
</motion.div>
);
}
export function StatCard({ title, value, subtitle, icon: Icon, color = 'cyan', trend, className }) {
const colors = {
cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20' },
green: { text: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/20' },
purple: { text: 'text-purple-400', bg: 'bg-purple-500/10', border: 'border-purple-500/20' },
orange: { text: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20' },
red: { text: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' },
blue: { text: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/20' },
};
const c = colors[color] ?? colors.cyan;
return (
<GlassCard className={cn('p-5', className)} glow={color === 'cyan' ? 'cyan' : undefined}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs font-medium text-white/50 uppercase tracking-wider mb-1">{title}</p>
<p className={cn('text-3xl font-bold tracking-tight', c.text)}>{value}</p>
{subtitle && <p className="text-xs text-white/40 mt-1">{subtitle}</p>}
{trend !== undefined && (
<p className={cn('text-xs mt-2', trend >= 0 ? 'text-green-400' : 'text-red-400')}>
{trend >= 0 ? '↑' : '↓'} {Math.abs(trend)}% vs last hour
</p>
)}
</div>
{Icon && (
<div className={cn('p-2.5 rounded-lg border', c.bg, c.border)}>
<Icon className={cn('w-5 h-5', c.text)} />
</div>
)}
</div>
</GlassCard>
);
}

View File

@@ -0,0 +1,54 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
export function Modal({ isOpen, onClose, title, children, size = 'md', className }) {
const sizes = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
full: 'max-w-[95vw]',
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-200" enterFrom="opacity-0" enterTo="opacity-100"
leave="ease-in duration-150" leaveFrom="opacity-100" leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-200" enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100"
leave="ease-in duration-150" leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className={cn(
'w-full rounded-2xl border border-white/10 bg-[#0a1628] shadow-2xl',
sizes[size],
className
)}>
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<Dialog.Title className="text-base font-semibold text-white">{title}</Dialog.Title>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-white/10 text-white/50 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="p-6">{children}</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,40 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function ProgressBar({ value, max = 100, className, showLabel = true, color, size = 'md' }) {
const pct = Math.min(100, Math.max(0, (value / max) * 100));
const autoColor = color ?? (
pct >= 90 ? 'red' :
pct >= 70 ? 'yellow' :
pct >= 50 ? 'cyan' : 'green'
);
const colors = {
cyan: { bar: 'bg-cyan-400', glow: 'shadow-[0_0_10px_rgba(0,212,255,0.5)]' },
green: { bar: 'bg-green-400', glow: 'shadow-[0_0_10px_rgba(0,255,136,0.5)]' },
yellow: { bar: 'bg-yellow-400', glow: 'shadow-[0_0_10px_rgba(255,255,0,0.5)]' },
red: { bar: 'bg-red-400', glow: 'shadow-[0_0_10px_rgba(255,45,85,0.5)]' },
purple: { bar: 'bg-purple-400', glow: 'shadow-[0_0_10px_rgba(157,78,221,0.5)]' },
};
const c = colors[autoColor] ?? colors.cyan;
const heights = { sm: 'h-1', md: 'h-1.5', lg: 'h-2' };
return (
<div className={cn('space-y-1', className)}>
{showLabel && (
<div className="flex justify-between text-xs text-white/50">
<span>{Math.round(pct)}%</span>
</div>
)}
<div className={cn('w-full bg-white/10 rounded-full overflow-hidden', heights[size])}>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className={cn('h-full rounded-full', c.bar, c.glow)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { cn, getStatusDot } from '@/lib/utils';
export function StatusBadge({ status, className }) {
const variants = {
online: 'bg-green-500/15 text-green-400 border-green-500/30',
running: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
active: 'bg-green-500/15 text-green-400 border-green-500/30',
stopped: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
offline: 'bg-red-500/15 text-red-400 border-red-500/30',
error: 'bg-red-500/15 text-red-400 border-red-500/30',
warning: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
unknown: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
paused: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
exited: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
success: 'bg-green-500/15 text-green-400 border-green-500/30',
failed: 'bg-red-500/15 text-red-400 border-red-500/30',
pending: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
queued: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
};
const variant = variants[status?.toLowerCase()] ?? variants.unknown;
const dot = getStatusDot(status);
return (
<span className={cn(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border',
variant,
className
)}>
<span className={cn('w-1.5 h-1.5 rounded-full', dot)} />
{status}
</span>
);
}

View File

@@ -0,0 +1,253 @@
import { useState, useEffect } from 'react';
import { Link, usePage } from '@inertiajs/react';
import { motion, AnimatePresence } from 'framer-motion';
import {
LayoutDashboard, Server, Monitor, Layers, Box, Boxes, Cloud,
Shield, HardDrive, Activity, KeyRound, BookOpen,
Users, Settings, ChevronLeft, ChevronRight, Bell, Search,
LogOut, User, Menu, X, Zap, ChevronDown
} from 'lucide-react';
import { cn, getStatusDot } from '@/lib/utils';
import { Button } from '@/Components/ui/Button';
const ICONS = {
LayoutDashboard, Server, Monitor, Layers, Box, Boxes, Cloud,
Shield, HardDrive, Activity, KeyRound, BookOpen, Users, Settings
};
function NavItem({ item, collapsed, active }) {
const Icon = ICONS[item.icon] ?? Server;
return (
<Link
href={item.href}
className={cn(
'group relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200',
active
? 'bg-cyan-500/15 text-cyan-400 border border-cyan-500/30'
: 'text-white/50 hover:text-white hover:bg-white/5'
)}
>
{active && (
<motion.div
layoutId="nav-active"
className="absolute inset-0 rounded-xl bg-cyan-500/10 border border-cyan-500/20"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<Icon className={cn('w-4 h-4 shrink-0 relative z-10', active && 'text-cyan-400')} />
{!collapsed && (
<span className="relative z-10 truncate">{item.name}</span>
)}
{collapsed && (
<div className="absolute left-full ml-3 px-2 py-1 rounded-lg bg-[#0f1f35] border border-white/10 text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-50">
{item.name}
</div>
)}
</Link>
);
}
export default function AppLayout({ children, title }) {
const { auth, navigation, flash, app } = usePage().props;
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const currentPath = window.location.pathname;
useEffect(() => {
setMobileOpen(false);
}, [currentPath]);
const isActive = (href) => {
if (href === '/') return currentPath === '/';
return currentPath.startsWith(href);
};
const topNav = navigation?.filter(n => !['acl', 'admin'].includes(n.key)) ?? [];
const adminNav = navigation?.filter(n => ['acl', 'admin'].includes(n.key)) ?? [];
return (
<div className="flex h-screen bg-[#050a0e] overflow-hidden">
{/* ── Sidebar ─────────────────────────────────────────── */}
<motion.aside
animate={{ width: collapsed ? 64 : 240 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className={cn(
'hidden lg:flex flex-col border-r border-white/[0.06] bg-[#07111d]',
'relative shrink-0 overflow-hidden'
)}
>
{/* Logo */}
<div className="flex items-center gap-3 h-16 px-4 border-b border-white/[0.06]">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500 to-purple-600 flex items-center justify-center shrink-0">
<Zap className="w-4 h-4 text-white" />
</div>
{!collapsed && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<p className="text-sm font-bold text-white leading-tight">{app?.name ?? 'Infra Manager'}</p>
<p className="text-[10px] text-white/30">v{app?.version ?? '1.0.0'}</p>
</motion.div>
)}
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden custom-scroll px-2 py-4 space-y-0.5">
{topNav.map(item => (
<NavItem key={item.key} item={item} collapsed={collapsed} active={isActive(item.href)} />
))}
{adminNav.length > 0 && (
<>
<div className={cn('my-3 border-t border-white/[0.06]', collapsed && 'mx-2')} />
{!collapsed && <p className="px-3 text-[10px] text-white/25 uppercase tracking-wider mb-1">Admin</p>}
{adminNav.map(item => (
<NavItem key={item.key} item={item} collapsed={collapsed} active={isActive(item.href)} />
))}
</>
)}
</nav>
{/* Collapse toggle */}
<button
onClick={() => setCollapsed(c => !c)}
className="absolute top-1/2 -right-3 z-20 w-6 h-6 rounded-full bg-[#0f1f35] border border-white/20 flex items-center justify-center hover:bg-white/10 transition-colors text-white/50 hover:text-white"
>
{collapsed ? <ChevronRight className="w-3 h-3" /> : <ChevronLeft className="w-3 h-3" />}
</button>
{/* User */}
<div className={cn('border-t border-white/[0.06] p-3', collapsed ? 'px-2' : '')}>
<Link
href="/admin"
className="flex items-center gap-3 rounded-xl p-2 hover:bg-white/5 transition-colors group"
>
<img
src={auth?.user?.avatar}
alt={auth?.user?.name}
className="w-8 h-8 rounded-full ring-1 ring-white/20 shrink-0"
/>
{!collapsed && (
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-white truncate">{auth?.user?.name}</p>
<p className="text-[10px] text-white/30 truncate">{auth?.user?.roles?.[0]}</p>
</div>
)}
</Link>
<form method="POST" action="/logout" className="mt-1">
<input type="hidden" name="_token" value={document.querySelector('meta[name=csrf-token]')?.content} />
<button
type="submit"
className={cn(
'w-full flex items-center gap-3 px-2 py-2 rounded-xl text-white/40 hover:text-red-400 hover:bg-red-500/10 transition-all text-xs',
collapsed && 'justify-center'
)}
>
<LogOut className="w-3.5 h-3.5 shrink-0" />
{!collapsed && 'Sign out'}
</button>
</form>
</div>
</motion.aside>
{/* ── Mobile Sidebar ──────────────────────────────────── */}
<AnimatePresence>
{mobileOpen && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setMobileOpen(false)}
/>
<motion.aside
initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed left-0 top-0 bottom-0 z-40 w-64 bg-[#07111d] border-r border-white/[0.06] flex flex-col lg:hidden"
>
<div className="flex items-center justify-between h-16 px-4 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500 to-purple-600 flex items-center justify-center">
<Zap className="w-4 h-4 text-white" />
</div>
<p className="text-sm font-bold text-white">{app?.name ?? 'Infra Manager'}</p>
</div>
<button onClick={() => setMobileOpen(false)} className="text-white/50 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 overflow-y-auto custom-scroll px-2 py-4 space-y-0.5">
{navigation?.map(item => (
<NavItem key={item.key} item={item} collapsed={false} active={isActive(item.href)} />
))}
</nav>
</motion.aside>
</>
)}
</AnimatePresence>
{/* ── Main content ────────────────────────────────────── */}
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Top Bar */}
<header className="h-16 border-b border-white/[0.06] bg-[#07111d]/80 backdrop-blur-xl flex items-center gap-4 px-4 lg:px-6 shrink-0">
<button onClick={() => setMobileOpen(true)} className="lg:hidden text-white/50 hover:text-white">
<Menu className="w-5 h-5" />
</button>
{title && (
<h1 className="text-sm font-semibold text-white/90 hidden sm:block">{title}</h1>
)}
<div className="flex-1" />
{/* Notifications */}
<button
onClick={() => setNotifOpen(n => !n)}
className="relative p-2 rounded-lg hover:bg-white/5 text-white/50 hover:text-white transition-colors"
>
<Bell className="w-4 h-4" />
{flash?.error && (
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-red-400" />
)}
</button>
{/* App env badge */}
{app?.env !== 'production' && (
<span className="hidden sm:block text-[10px] px-2 py-0.5 rounded-full bg-yellow-500/15 text-yellow-400 border border-yellow-500/30 font-medium">
{app?.env?.toUpperCase()}
</span>
)}
</header>
{/* Flash messages */}
<AnimatePresence>
{(flash?.success || flash?.error || flash?.warning) && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className={cn(
'mx-4 lg:mx-6 mt-4 px-4 py-3 rounded-xl border text-sm flex items-center gap-3',
flash?.success && 'bg-green-500/15 border-green-500/30 text-green-300',
flash?.error && 'bg-red-500/15 border-red-500/30 text-red-300',
flash?.warning && 'bg-yellow-500/15 border-yellow-500/30 text-yellow-300'
)}
>
<span>{flash?.success || flash?.error || flash?.warning}</span>
</motion.div>
)}
</AnimatePresence>
{/* Page Content */}
<main className="flex-1 overflow-y-auto custom-scroll">
<div className="p-4 lg:p-6">
{children}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { Users, Shield, Plus, Edit, Trash2, UserCheck } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { timeAgo } from '@/lib/utils';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
import { useState } from 'react';
export default function AclIndex({ users, roles }) {
const [tab, setTab] = useState('users');
const userColumns = [
{ header: 'User', accessor: 'name', render: row => (
<div className="flex items-center gap-3">
<img src={row.avatar_url} className="w-8 h-8 rounded-full ring-1 ring-white/20" alt={row.name} />
<div>
<p className="text-sm font-medium text-white">{row.name}</p>
<p className="text-xs text-white/40">{row.email}</p>
</div>
</div>
)},
{ header: 'Auth', accessor: 'auth_source', render: row => (
<span className={cn('text-xs px-2 py-0.5 rounded capitalize',
row.auth_source === 'ldap' ? 'bg-purple-500/15 text-purple-400' : 'bg-white/5 text-white/50'
)}>{row.auth_source}</span>
)},
{ header: 'Roles', key: 'roles', render: row => (
<div className="flex flex-wrap gap-1">
{(row.roles ?? []).map(r => (
<span key={r} className="text-xs px-2 py-0.5 rounded bg-cyan-500/15 text-cyan-400">{r}</span>
))}
</div>
)},
{ header: 'Status', accessor: 'is_active', render: row => (
<StatusBadge status={row.is_active ? 'active' : 'offline'} />
)},
{ header: 'Last Login', accessor: 'last_login_at', render: row => (
<span className="text-xs text-white/40">{timeAgo(row.last_login_at)}</span>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex gap-1" onClick={e => e.stopPropagation()}>
<a href={`/acl/users/${row.id}/edit`}><Button size="sm" variant="ghost" icon={Edit} /></a>
<Button size="sm" variant="ghost" icon={Trash2}
onClick={() => confirm('Delete this user?') && router.delete(`/acl/users/${row.id}`)} />
</div>
)},
];
const roleColumns = [
{ header: 'Role', accessor: 'name', render: row => (
<div>
<p className="text-sm font-medium text-white capitalize">{row.name}</p>
<p className="text-xs text-white/30">{row.permissions_count ?? 0} permissions</p>
</div>
)},
{ header: 'Users', key: 'users', render: row => (
<span className="text-sm text-white/60">{row.users_count ?? 0}</span>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex gap-1">
<a href={`/acl/roles/${row.id}/edit`}><Button size="sm" variant="ghost" icon={Edit} /></a>
</div>
)},
];
return (
<AppLayout title="ACL">
<Head title="Access Control" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Access Control</h2>
<p className="text-sm text-white/40">Users, roles, and permissions management</p>
</div>
<div className="flex gap-2">
{tab === 'users' && <a href="/acl/users/create"><Button icon={Plus}>Add User</Button></a>}
{tab === 'roles' && <a href="/acl/roles/create"><Button icon={Plus}>Add Role</Button></a>}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard title="Total Users" value={users?.total ?? 0} icon={Users} color="cyan" />
<StatCard title="Active" value={users?.data?.filter(u => u.is_active).length ?? 0} icon={UserCheck} color="green" />
<StatCard title="LDAP Users" value={users?.data?.filter(u => u.auth_source === 'ldap').length ?? 0} icon={Shield} color="purple" />
<StatCard title="Roles" value={roles?.length ?? 0} icon={Shield} color="blue" />
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="flex gap-1 p-1 bg-white/5 rounded-xl w-fit border border-white/10 mb-4">
{[{ value: 'users', label: 'Users' }, { value: 'roles', label: 'Roles & Permissions' }].map(t => (
<TabsTrigger key={t.value} value={t.value}
className={cn('px-5 py-2 rounded-lg text-sm font-medium transition-all',
tab === t.value ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white'
)}>{t.label}</TabsTrigger>
))}
</TabsList>
<TabsContent value="users">
<GlassCard className="p-5">
<DataTable columns={userColumns} data={users?.data ?? []}
onRowClick={row => router.visit(`/acl/users/${row.id}/edit`)}
emptyMessage="No users found." />
</GlassCard>
</TabsContent>
<TabsContent value="roles">
<GlassCard className="p-5">
<DataTable columns={roleColumns} data={roles ?? []}
emptyMessage="No roles defined." />
</GlassCard>
</TabsContent>
</Tabs>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,93 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { Settings, Users, Activity, Shield, Database, Globe, Server, ChevronRight } from 'lucide-react';
import { GlassCard } from '@/Components/ui/GlassCard';
import { Button } from '@/Components/ui/Button';
import { motion } from 'framer-motion';
import { timeAgo } from '@/lib/utils';
const adminSections = [
{ title: 'General Settings', href: '/admin/settings', icon: Settings, desc: 'App name, theme, session timeout' },
{ title: 'LDAP / SSO', href: '/admin/ldap', icon: Globe, desc: 'Configure LDAP/AD authentication' },
{ title: 'Users', href: '/acl/users', icon: Users, desc: 'Manage user accounts' },
{ title: 'Roles & ACL', href: '/acl/roles', icon: Shield, desc: 'Roles and permission management' },
{ title: 'Audit Log', href: '/admin/audit-log', icon: Activity, desc: 'User activity and system events' },
{ title: 'System Health', href: '/admin/system-health',icon: Server, desc: 'Database, queues, storage status' },
];
export default function AdminIndex({ stats, recent_activity }) {
return (
<AppLayout title="Administration">
<Head title="Administration" />
<div className="space-y-5">
<div>
<h2 className="text-xl font-bold text-white">Administration</h2>
<p className="text-sm text-white/40">System settings, users, and maintenance</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{adminSections.map(s => {
const Icon = s.icon;
return (
<a key={s.href} href={s.href}>
<GlassCard className="p-5 cursor-pointer group" hover glow="cyan">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center group-hover:border-cyan-500/30 group-hover:bg-cyan-500/10 transition-all">
<Icon className="w-5 h-5 text-white/50 group-hover:text-cyan-400 transition-colors" />
</div>
<div>
<p className="text-sm font-medium text-white group-hover:text-cyan-300 transition-colors">{s.title}</p>
<p className="text-xs text-white/30">{s.desc}</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-white/20 group-hover:text-white/50 transition-colors" />
</div>
</GlassCard>
</a>
);
})}
</div>
{/* System stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: 'Total Users', value: stats?.users ?? 0 },
{ label: 'Active Sessions', value: stats?.sessions ?? 0 },
{ label: 'Queue Jobs', value: stats?.queued ?? 0 },
{ label: 'DB Size', value: stats?.db_size ?? '—' },
].map(s => (
<GlassCard key={s.label} className="p-4 text-center">
<p className="text-2xl font-bold text-white">{s.value}</p>
<p className="text-xs text-white/40 mt-0.5">{s.label}</p>
</GlassCard>
))}
</div>
{/* Recent activity */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Recent Activity</h3>
<a href="/admin/audit-log"><Button size="sm" variant="ghost">View All →</Button></a>
</div>
<div className="space-y-2">
{(recent_activity ?? []).map((log, i) => (
<div key={i} className="flex items-start gap-3 py-2 border-b border-white/[0.05] last:border-0">
<div className="w-7 h-7 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-xs shrink-0">
{log.causer?.name?.charAt(0)?.toUpperCase() ?? '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white/80">{log.description}</p>
<p className="text-xs text-white/30">{log.causer?.name ?? 'System'} · {timeAgo(log.created_at)}</p>
</div>
</div>
))}
{(!recent_activity || recent_activity.length === 0) && (
<p className="text-sm text-white/30 text-center py-4">No recent activity</p>
)}
</div>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,146 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, useForm, router } from '@inertiajs/react';
import { useState } from 'react';
import { Globe, CheckCircle, XCircle, RefreshCw } from 'lucide-react';
import { GlassCard } from '@/Components/ui/GlassCard';
import { Button } from '@/Components/ui/Button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
function Input({ className, ...props }) {
return <input className={cn('w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder:text-white/20 focus:outline-none focus:border-cyan-500/60 transition-all', className)} {...props} />;
}
function Field({ label, desc, children }) {
return (
<div className="flex items-start justify-between gap-8 py-4 border-b border-white/[0.06] last:border-0">
<div className="flex-1">
<p className="text-sm font-medium text-white">{label}</p>
{desc && <p className="text-xs text-white/40 mt-0.5">{desc}</p>}
</div>
<div className="w-72 shrink-0">{children}</div>
</div>
);
}
export default function LdapConfig({ config }) {
const { data, setData, put, processing } = useForm({
host: config?.host ?? '',
port: config?.port ?? 389,
use_ssl: config?.use_ssl ?? false,
use_tls: config?.use_tls ?? false,
base_dn: config?.base_dn ?? '',
bind_dn: config?.bind_dn ?? '',
bind_password: '',
user_search_base: config?.user_search_base ?? '',
user_filter: config?.user_filter ?? '(sAMAccountName={username})',
admin_group_dn: config?.admin_group_dn ?? '',
is_active: config?.is_active ?? false,
});
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const [syncing, setSyncing] = useState(false);
const test = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await fetch('/admin/ldap/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
body: JSON.stringify(data),
});
setTestResult(await res.json());
} catch (e) {
setTestResult({ success: false, message: e.message });
} finally {
setTesting(false);
}
};
const sync = () => {
setSyncing(true);
router.post('/admin/ldap/sync', {}, { onFinish: () => setSyncing(false) });
};
return (
<AppLayout title="LDAP Configuration">
<Head title="LDAP" />
<div className="max-w-3xl space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">LDAP / Active Directory</h2>
<p className="text-sm text-white/40">Integrate with your directory service for SSO</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" icon={RefreshCw} loading={syncing} onClick={sync}>Sync Users</Button>
</div>
</div>
<form onSubmit={e => { e.preventDefault(); put('/admin/ldap'); }}>
<div className="space-y-4">
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">Connection</h3>
<Field label="Enable LDAP" desc="Allow users to authenticate via LDAP">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={data.is_active} onChange={e => setData('is_active', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-0" />
<span className="text-sm text-white/70">Enabled</span>
</label>
</Field>
<Field label="LDAP Host">
<Input value={data.host} onChange={e => setData('host', e.target.value)} placeholder="ldap.company.com" />
</Field>
<Field label="Port">
<Input type="number" value={data.port} onChange={e => setData('port', parseInt(e.target.value))} />
</Field>
<Field label="Encryption">
<div className="flex gap-3">
{[['use_ssl', 'LDAPS (SSL)'], ['use_tls', 'STARTTLS']].map(([k, l]) => (
<label key={k} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={data[k]} onChange={e => setData(k, e.target.checked)}
className="w-3.5 h-3.5 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-0" />
<span className="text-xs text-white/60">{l}</span>
</label>
))}
</div>
</Field>
</GlassCard>
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">Bind / Authentication</h3>
<Field label="Base DN"><Input value={data.base_dn} onChange={e => setData('base_dn', e.target.value)} placeholder="DC=company,DC=com" /></Field>
<Field label="Bind DN"><Input value={data.bind_dn} onChange={e => setData('bind_dn', e.target.value)} placeholder="CN=ldap-reader,CN=Users,DC=company,DC=com" /></Field>
<Field label="Bind Password"><Input type="password" value={data.bind_password} onChange={e => setData('bind_password', e.target.value)} placeholder="Leave blank to keep existing" /></Field>
</GlassCard>
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">User Search</h3>
<Field label="User Search Base"><Input value={data.user_search_base} onChange={e => setData('user_search_base', e.target.value)} placeholder="CN=Users,DC=company,DC=com" /></Field>
<Field label="User Filter" desc="Use {username} as placeholder">
<Input value={data.user_filter} onChange={e => setData('user_filter', e.target.value)} />
</Field>
<Field label="Admin Group DN" desc="Members of this group get admin role">
<Input value={data.admin_group_dn} onChange={e => setData('admin_group_dn', e.target.value)} placeholder="CN=Infra-Admins,CN=Groups,..." />
</Field>
</GlassCard>
{testResult && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
className={cn('flex items-center gap-3 p-3 rounded-xl border text-sm',
testResult.success ? 'bg-green-500/10 border-green-500/30 text-green-300' : 'bg-red-500/10 border-red-500/30 text-red-300'
)}>
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{testResult.message}
</motion.div>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="secondary" loading={testing} onClick={test}>Test Connection</Button>
<Button type="submit" loading={processing}>Save Configuration</Button>
</div>
</div>
</form>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,115 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, useForm } from '@inertiajs/react';
import { GlassCard } from '@/Components/ui/GlassCard';
import { Button } from '@/Components/ui/Button';
import { cn } from '@/lib/utils';
function Field({ label, desc, error, children }) {
return (
<div className="flex items-start justify-between gap-8 py-4 border-b border-white/[0.06] last:border-0">
<div className="flex-1">
<p className="text-sm font-medium text-white">{label}</p>
{desc && <p className="text-xs text-white/40 mt-0.5">{desc}</p>}
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
</div>
<div className="w-72 shrink-0">{children}</div>
</div>
);
}
function Input({ className, ...props }) {
return (
<input className={cn(
'w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder:text-white/20',
'focus:outline-none focus:border-cyan-500/60 transition-all',
className
)} {...props} />
);
}
function Select({ className, children, ...props }) {
return (
<select className={cn(
'w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white',
'focus:outline-none focus:border-cyan-500/60 transition-all',
'[&>option]:bg-[#0f1f35]',
className
)} {...props}>{children}</select>
);
}
export default function AdminSettings({ settings }) {
const { data, setData, put, processing, errors } = useForm({
'app.name': settings?.['app.name'] ?? 'Infra Manager',
'app.theme': settings?.['app.theme'] ?? 'dark',
'security.session_timeout': settings?.['security.session_timeout'] ?? 480,
'security.mfa_required': settings?.['security.mfa_required'] ?? false,
'mail.driver': settings?.['mail.driver'] ?? 'log',
'mail.from': settings?.['mail.from'] ?? '',
});
return (
<AppLayout title="Settings">
<Head title="Settings" />
<div className="max-w-3xl space-y-5">
<div>
<h2 className="text-xl font-bold text-white">General Settings</h2>
<p className="text-sm text-white/40">Application-wide configuration</p>
</div>
<form onSubmit={e => { e.preventDefault(); put('/admin/settings'); }}>
<div className="space-y-4">
{/* App */}
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">Application</h3>
<Field label="App Name" desc="Displayed in the browser tab and sidebar">
<Input value={data['app.name']} onChange={e => setData('app.name', e.target.value)} />
</Field>
<Field label="Theme" desc="Interface color scheme">
<Select value={data['app.theme']} onChange={e => setData('app.theme', e.target.value)}>
<option value="dark">Dark (Default)</option>
<option value="darker">Darker</option>
</Select>
</Field>
</GlassCard>
{/* Security */}
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">Security</h3>
<Field label="Session Timeout" desc="Minutes before inactive users are logged out">
<Input type="number" value={data['security.session_timeout']} onChange={e => setData('security.session_timeout', e.target.value)} />
</Field>
<Field label="Require MFA" desc="Force MFA for all users">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={data['security.mfa_required']}
onChange={e => setData('security.mfa_required', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-0" />
<span className="text-sm text-white/70">Enabled</span>
</label>
</Field>
</GlassCard>
{/* Mail */}
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider mb-1">Email / Notifications</h3>
<Field label="Mail Driver">
<Select value={data['mail.driver']} onChange={e => setData('mail.driver', e.target.value)}>
<option value="log">Log (dev only)</option>
<option value="smtp">SMTP</option>
<option value="sendmail">Sendmail</option>
</Select>
</Field>
<Field label="From Address" desc="Default sender email for notifications">
<Input type="email" value={data['mail.from']} onChange={e => setData('mail.from', e.target.value)} placeholder="noreply@example.com" />
</Field>
</GlassCard>
<div className="flex justify-end">
<Button type="submit" loading={processing}>Save Settings</Button>
</div>
</div>
</form>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,154 @@
import { useForm, Head } from '@inertiajs/react';
import { motion } from 'framer-motion';
import { Zap, Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/Components/ui/Button';
import { cn } from '@/lib/utils';
export default function Login({ ldapEnabled }) {
const { data, setData, post, processing, errors } = useForm({
email: '',
password: '',
remember: false,
});
const [showPass, setShowPass] = useState(false);
const submit = (e) => {
e.preventDefault();
post('/login');
};
return (
<>
<Head title="Sign In" />
<div className="min-h-screen bg-[#050a0e] flex items-center justify-center p-4 relative overflow-hidden">
{/* Background effects */}
<div className="absolute inset-0 bg-grid-pattern opacity-50" />
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/5 rounded-full blur-3xl" />
{/* Meteors */}
{Array.from({ length: 8 }).map((_, i) => (
<motion.div
key={i}
className="absolute w-0.5 h-16 bg-gradient-to-b from-cyan-400/60 to-transparent rounded-full"
style={{ top: `${Math.random() * 100}%`, left: `${Math.random() * 100}%`, rotate: -45 }}
animate={{ x: [0, -300], y: [0, 300], opacity: [0, 1, 0] }}
transition={{ duration: 2 + Math.random() * 3, repeat: Infinity, delay: i * 0.7, ease: 'linear' }}
/>
))}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md relative z-10"
>
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-cyan-500 to-purple-600 mb-4 shadow-[0_0_40px_rgba(0,212,255,0.3)]">
<Zap className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white">Infra Manager</h1>
<p className="text-sm text-white/40 mt-1">Unified Infrastructure Control Plane</p>
</div>
{/* Card */}
<div className="relative rounded-2xl border border-white/10 bg-white/[0.03] backdrop-blur-xl p-8">
{/* Gradient border glow */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-cyan-500/10 via-transparent to-purple-500/10 pointer-events-none" />
<form onSubmit={submit} className="relative space-y-5">
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">Email Address</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
placeholder="admin@infra.local"
required
className={cn(
'w-full pl-10 pr-4 py-2.5 bg-white/5 border rounded-xl text-sm text-white placeholder:text-white/20',
'focus:outline-none focus:border-cyan-500/60 focus:bg-white/[0.07] transition-all',
errors.email ? 'border-red-500/60' : 'border-white/10'
)}
/>
</div>
{errors.email && (
<p className="flex items-center gap-1 mt-1.5 text-xs text-red-400">
<AlertCircle className="w-3 h-3" />{errors.email}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-1.5">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type={showPass ? 'text' : 'password'}
value={data.password}
onChange={e => setData('password', e.target.value)}
placeholder="••••••••"
required
className={cn(
'w-full pl-10 pr-10 py-2.5 bg-white/5 border rounded-xl text-sm text-white placeholder:text-white/20',
'focus:outline-none focus:border-cyan-500/60 focus:bg-white/[0.07] transition-all',
errors.password ? 'border-red-500/60' : 'border-white/10'
)}
/>
<button
type="button"
onClick={() => setShowPass(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={data.remember}
onChange={e => setData('remember', e.target.checked)}
className="w-3.5 h-3.5 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-0 focus:ring-offset-0"
/>
<span className="text-xs text-white/50">Remember me</span>
</label>
</div>
<Button
type="submit"
variant="primary"
size="lg"
loading={processing}
className="w-full"
>
Sign In
</Button>
{ldapEnabled && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-[#07111d] px-2 text-white/30">or</span>
</div>
</div>
)}
</form>
</div>
<p className="text-center text-xs text-white/20 mt-6">
Default: admin@infra.local / Admin@123456
</p>
</motion.div>
</div>
</>
);
}

View File

@@ -0,0 +1,114 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { HardDrive, Plus, Download, RotateCcw, Calendar, CheckCircle, XCircle, Clock } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { formatBytes, timeAgo } from '@/lib/utils';
export default function BackupIndex({ backups, policies, stats }) {
const backupColumns = [
{ header: 'Target', accessor: 'target_name', render: row => (
<div>
<p className="text-sm text-white font-medium">{row.target_name}</p>
<p className="text-xs text-white/30 capitalize">{row.target_type}</p>
</div>
)},
{ header: 'Size', accessor: 'size_bytes', render: row => (
<span className="text-sm text-white/70 font-mono">{row.size_bytes ? formatBytes(row.size_bytes) : '—'}</span>
)},
{ header: 'Status', accessor: 'status', render: row => <StatusBadge status={row.status} /> },
{ header: 'Created', accessor: 'created_at', render: row => (
<span className="text-xs text-white/40">{timeAgo(row.created_at)}</span>
)},
{ header: 'Expires', accessor: 'expires_at', render: row => (
row.expires_at
? <span className={`text-xs ${new Date(row.expires_at) < new Date() ? 'text-red-400' : 'text-white/40'}`}>{timeAgo(row.expires_at)}</span>
: <span className="text-xs text-white/20">Never</span>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex gap-1" onClick={e => e.stopPropagation()}>
{row.status === 'success' && (
<>
<Button size="sm" variant="ghost" icon={Download} title="Download" />
<Button size="sm" variant="ghost" icon={RotateCcw} title="Restore"
onClick={() => confirm('Restore from this backup?') && router.post(`/backups/${row.id}/restore`)} />
</>
)}
<Button size="sm" variant="ghost" icon={XCircle} className="hover:text-red-400"
onClick={() => confirm('Delete this backup?') && router.delete(`/backups/${row.id}`)} />
</div>
)},
];
const policyColumns = [
{ header: 'Policy', accessor: 'name', render: row => (
<div>
<p className="text-sm text-white font-medium">{row.name}</p>
<p className="text-xs text-white/30 capitalize">{row.target_type} · {row.schedule}</p>
</div>
)},
{ header: 'Retention', accessor: 'retention_value', render: row => (
<span className="text-sm text-white/70">{row.retention_value} {row.retention_type === 'count' ? 'backups' : 'days'}</span>
)},
{ header: 'Storage', accessor: 'storage_type', render: row => (
<span className="text-xs px-2 py-0.5 rounded bg-white/5 text-white/60 uppercase">{row.storage_type}</span>
)},
{ header: 'Status', accessor: 'is_active', render: row => (
<StatusBadge status={row.is_active ? 'active' : 'stopped'} />
)},
];
return (
<AppLayout title="Backup & DR">
<Head title="Backup & DR" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Backup & Disaster Recovery</h2>
<p className="text-sm text-white/40">Manage backups, schedules, and restore points</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" icon={Calendar}>Schedules</Button>
<Button icon={Plus}>Create Policy</Button>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard title="Total Backups" value={stats?.total ?? 0} icon={HardDrive} color="cyan" />
<StatCard title="Successful" value={stats?.success ?? 0} icon={CheckCircle} color="green" />
<StatCard title="Failed" value={stats?.failed ?? 0} icon={XCircle} color="red" />
<StatCard title="Storage Used" value={stats?.total_size ?? '0 B'} icon={HardDrive} color="purple" />
</div>
{/* DR Status */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{[
{ label: 'RTO', value: '< 4h', desc: 'Recovery Time Objective', color: 'text-green-400' },
{ label: 'RPO', value: '< 1h', desc: 'Recovery Point Objective', color: 'text-cyan-400' },
{ label: 'Last DR Test', value: 'N/A', desc: 'Last tested', color: 'text-white/40' },
].map(d => (
<GlassCard key={d.label} className="p-5 text-center">
<p className="text-xs text-white/40 uppercase tracking-wider mb-1">{d.label}</p>
<p className={`text-3xl font-bold ${d.color}`}>{d.value}</p>
<p className="text-xs text-white/30 mt-1">{d.desc}</p>
</GlassCard>
))}
</div>
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-4">Backup Policies</h3>
<DataTable columns={policyColumns} data={policies ?? []}
emptyMessage="No backup policies. Create one to start protecting your infrastructure." />
</GlassCard>
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-4">Backup History</h3>
<DataTable columns={backupColumns} data={backups?.data ?? []}
emptyMessage="No backups found." />
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,150 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { Cloud, Plus, RefreshCw, Server, HardDrive, Database, Globe } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { timeAgo } from '@/lib/utils';
import { motion } from 'framer-motion';
import { useState } from 'react';
const PROVIDERS = {
aws: { label: 'Amazon Web Services', color: '#FF9900', icon: '☁' },
alibaba: { label: 'Alibaba Cloud', color: '#FF6A00', icon: '☁' },
tencent: { label: 'Tencent Cloud', color: '#00A3FF', icon: '☁' },
gcp: { label: 'Google Cloud', color: '#4285F4', icon: '☁' },
azure: { label: 'Microsoft Azure', color: '#0078D4', icon: '☁' },
digitalocean: { label: 'DigitalOcean', color: '#0080FF', icon: '☁' },
vultr: { label: 'Vultr', color: '#007BFC', icon: '☁' },
hetzner: { label: 'Hetzner', color: '#D50C2D', icon: '☁' },
};
const RESOURCE_ICONS = { ec2: Server, s3: HardDrive, rds: Database, vpc: Globe };
export default function CloudIndex({ accounts, resources, stats }) {
const [syncLoading, setSyncLoading] = useState(null);
const [selectedAccount, setSelectedAccount] = useState(null);
const sync = (id) => {
setSyncLoading(id);
router.post(`/cloud/accounts/${id}/sync`, {}, { onFinish: () => setSyncLoading(null) });
};
const accountColumns = [
{ header: 'Account', accessor: 'name', render: row => {
const p = PROVIDERS[row.provider] ?? {};
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center text-base font-bold"
style={{ color: p.color }}>{row.provider?.toUpperCase().slice(0, 2)}</div>
<div>
<p className="font-medium text-white">{row.name}</p>
<p className="text-xs" style={{ color: p.color ?? '#fff' }}>{p.label ?? row.provider}</p>
</div>
</div>
);
}},
{ header: 'Account ID', accessor: 'account_id', render: row => (
<span className="font-mono text-xs text-white/50">{row.account_id ?? '—'}</span>
)},
{ header: 'Region', accessor: 'region', render: row => (
<span className="text-xs text-white/60">{row.region ?? '—'}</span>
)},
{ header: 'Status', accessor: 'status', render: row => <StatusBadge status={row.status} /> },
{ header: 'Last Sync', accessor: 'last_sync_at', render: row => (
<span className="text-xs text-white/40">{timeAgo(row.last_sync_at)}</span>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<button onClick={() => sync(row.id)} disabled={syncLoading === row.id}
className="p-1.5 rounded-lg hover:bg-white/10 text-white/40 hover:text-cyan-400 transition-colors">
<RefreshCw className={`w-3.5 h-3.5 ${syncLoading === row.id ? 'animate-spin' : ''}`} />
</button>
</div>
)},
];
const resourceColumns = [
{ header: 'Resource', accessor: 'name', render: row => {
const Icon = RESOURCE_ICONS[row.resource_type] ?? Cloud;
return (
<div className="flex items-center gap-3">
<Icon className="w-4 h-4 text-white/40" />
<div>
<p className="text-sm text-white">{row.name}</p>
<p className="font-mono text-xs text-white/30">{row.resource_id}</p>
</div>
</div>
);
}},
{ header: 'Type', accessor: 'resource_type', render: row => (
<span className="font-mono text-xs px-2 py-0.5 rounded bg-white/5 text-white/60">{row.resource_type}</span>
)},
{ header: 'Region', accessor: 'region', render: row => (
<span className="text-xs text-white/60">{row.region}</span>
)},
{ header: 'Status', accessor: 'status', render: row => <StatusBadge status={row.status} /> },
{ header: 'Cost/mo', accessor: 'monthly_cost', render: row => (
row.monthly_cost ? <span className="text-xs text-green-400 font-mono">${row.monthly_cost}</span> : <span className="text-xs text-white/20">—</span>
)},
];
return (
<AppLayout title="Cloud">
<Head title="Cloud Management" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Cloud Management</h2>
<p className="text-sm text-white/40">AWS, Alibaba, Tencent, GCP, Azure and more</p>
</div>
<a href="/cloud/accounts/create">
<Button icon={Plus}>Add Account</Button>
</a>
</div>
{/* Provider cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(PROVIDERS).slice(0, 4).map(([key, p]) => {
const count = accounts?.filter(a => a.provider === key).length ?? 0;
return (
<GlassCard key={key} className="p-4">
<div className="flex items-center gap-3">
<div className="text-2xl font-bold" style={{ color: p.color }}>
{key.toUpperCase().slice(0, 2)}
</div>
<div>
<p className="text-sm text-white font-medium">{key.toUpperCase()}</p>
<p className="text-xs text-white/40">{count} account{count !== 1 ? 's' : ''}</p>
</div>
</div>
</GlassCard>
);
})}
</div>
{/* Accounts */}
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-4">Cloud Accounts</h3>
<DataTable
columns={accountColumns}
data={accounts ?? []}
onRowClick={row => router.visit(`/cloud/accounts/${row.id}`)}
emptyMessage="No cloud accounts. Add your first account to get started."
/>
</GlassCard>
{/* Resources */}
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-4">Cloud Resources</h3>
<DataTable
columns={resourceColumns}
data={resources?.data ?? []}
emptyMessage="No resources synced yet. Sync a cloud account to see resources."
/>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,131 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { motion } from 'framer-motion';
import { Box, Play, Square, RotateCcw, Trash2, Terminal, BarChart2, Plus } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { useState } from 'react';
import { cn } from '@/lib/utils';
export default function ContainerIndex({ containers, endpoints }) {
const [actionLoading, setActionLoading] = useState(null);
const [selectedEndpoint, setSelectedEndpoint] = useState(null);
const action = (containerId, act) => {
setActionLoading(`${containerId}-${act}`);
router.post(`/containers/${containerId}/action`, { action: act }, {
onFinish: () => setActionLoading(null),
});
};
const filtered = selectedEndpoint
? (containers.data ?? []).filter(c => c.endpoint_id === selectedEndpoint)
: (containers.data ?? []);
const columns = [
{ header: 'Container', accessor: 'name', render: row => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
<Box className="w-4 h-4 text-blue-400" />
</div>
<div>
<p className="font-medium text-white font-mono text-sm">{row.name}</p>
<p className="text-xs text-white/30 truncate max-w-48">{row.image}</p>
</div>
</div>
)},
{ header: 'Status', accessor: 'state', render: row => <StatusBadge status={row.state} /> },
{ header: 'Ports', key: 'ports', render: row => (
<div className="flex flex-wrap gap-1">
{(row.ports ?? []).slice(0, 3).map((p, i) => (
<span key={i} className="font-mono text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/50">
{p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : p.PrivatePort}
</span>
))}
{(row.ports ?? []).length > 3 && <span className="text-xs text-white/30">+{row.ports.length - 3}</span>}
</div>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
{row.state === 'running' ? (
<>
<button onClick={() => action(row.id, 'stop')} disabled={actionLoading?.startsWith(row.id)}
className="p-1.5 rounded-lg hover:bg-red-500/20 text-white/40 hover:text-red-400 transition-colors" title="Stop">
<Square className="w-3.5 h-3.5" />
</button>
<button onClick={() => action(row.id, 'restart')} disabled={actionLoading?.startsWith(row.id)}
className="p-1.5 rounded-lg hover:bg-yellow-500/20 text-white/40 hover:text-yellow-400 transition-colors" title="Restart">
<RotateCcw className="w-3.5 h-3.5" />
</button>
</>
) : (
<button onClick={() => action(row.id, 'start')} disabled={actionLoading?.startsWith(row.id)}
className="p-1.5 rounded-lg hover:bg-green-500/20 text-white/40 hover:text-green-400 transition-colors" title="Start">
<Play className="w-3.5 h-3.5" />
</button>
)}
<a href={`/containers/${row.id}/logs`} className="p-1.5 rounded-lg hover:bg-white/10 text-white/40 hover:text-white transition-colors" title="Logs">
<Terminal className="w-3.5 h-3.5" />
</a>
<a href={`/containers/${row.id}/stats`} className="p-1.5 rounded-lg hover:bg-white/10 text-white/40 hover:text-white transition-colors" title="Stats">
<BarChart2 className="w-3.5 h-3.5" />
</a>
</div>
)},
];
const running = (containers.data ?? []).filter(c => c.state === 'running').length;
const stopped = (containers.data ?? []).filter(c => c.state !== 'running').length;
return (
<AppLayout title="Containers">
<Head title="Containers" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Containers</h2>
<p className="text-sm text-white/40">Manage Docker containers across all endpoints</p>
</div>
<div className="flex gap-2">
<a href="/stacks"><Button variant="secondary">Stacks</Button></a>
<a href="/stacks/create"><Button icon={Plus}>Deploy Stack</Button></a>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard title="Endpoints" value={endpoints?.length ?? 0} color="cyan" />
<StatCard title="Total" value={containers.total ?? 0} color="blue" />
<StatCard title="Running" value={running} color="green" />
<StatCard title="Stopped" value={stopped} color="red" />
</div>
{/* Endpoint filter tabs */}
{endpoints?.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<button onClick={() => setSelectedEndpoint(null)}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
!selectedEndpoint ? 'border-cyan-500/50 bg-cyan-500/10 text-cyan-400' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20'
)}>All Endpoints</button>
{endpoints.map(e => (
<button key={e.id} onClick={() => setSelectedEndpoint(e.id)}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
selectedEndpoint === e.id ? 'border-cyan-500/50 bg-cyan-500/10 text-cyan-400' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20'
)}>{e.name}</button>
))}
</div>
)}
<GlassCard className="p-5">
<DataTable
columns={columns}
data={filtered}
onRowClick={row => router.visit(`/containers/${row.id}`)}
emptyMessage="No containers found. Add a Docker endpoint to get started."
/>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,238 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head } from '@inertiajs/react';
import { motion } from 'framer-motion';
import {
Server, Monitor, Box, Cloud, Shield, AlertTriangle,
Activity, TrendingUp, Cpu, MemoryStick, HardDrive, Wifi
} from 'lucide-react';
import { StatCard, GlassCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { ProgressBar } from '@/Components/ui/ProgressBar';
import { timeAgo, getStatusDot } from '@/lib/utils';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, PieChart, Pie, Cell
} from 'recharts';
const FADE_UP = { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0 } };
const CONTAINER = {
hidden: {},
show: { transition: { staggerChildren: 0.05 } }
};
// Mock chart data - replace with real metrics
const cpuData = Array.from({ length: 20 }, (_, i) => ({
t: `${i}m`, cpu: Math.round(20 + Math.random() * 50), mem: Math.round(40 + Math.random() * 40)
}));
const PROVIDER_COLORS = { aws: '#FF9900', alibaba: '#FF6A00', tencent: '#00A3FF', gcp: '#4285F4', azure: '#0078D4' };
export default function Dashboard({ stats, hypervisors, endpoints, cloud_accounts, recent_alerts }) {
return (
<AppLayout title="Dashboard">
<Head title="Dashboard" />
<motion.div variants={CONTAINER} initial="hidden" animate="show" className="space-y-6">
{/* ── Header ──────────────────────────────────────── */}
<motion.div variants={FADE_UP} className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white">Infrastructure Overview</h2>
<p className="text-sm text-white/40 mt-0.5">Real-time view across all platforms</p>
</div>
<div className="flex items-center gap-2 text-xs text-white/30">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
Live
</div>
</motion.div>
{/* ── Top stats ────────────────────────────────────── */}
<motion.div variants={FADE_UP} className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-3">
<StatCard title="Hypervisors" value={stats.hypervisors} subtitle={`${stats.hypervisors_online} online`} icon={Server} color="cyan" />
<StatCard title="Virtual Machines" value={stats.vms} subtitle={`${stats.vms_running} running`} icon={Monitor} color="green" />
<StatCard title="Containers" value={stats.containers} subtitle={`${stats.containers_running} running`} icon={Box} color="purple" />
<StatCard title="Cloud Accounts" value={stats.cloud_accounts} subtitle="Connected" icon={Cloud} color="blue" />
<StatCard title="Critical Alerts" value={stats.critical_alerts} subtitle={`${stats.warning_alerts} warnings`} icon={AlertTriangle} color={stats.critical_alerts > 0 ? 'red' : 'green'} />
<StatCard title="Security Score" value={`${stats.security_score}%`} subtitle="Avg across infra" icon={Shield} color={stats.security_score >= 80 ? 'green' : stats.security_score >= 60 ? 'yellow' : 'red'} />
</motion.div>
{/* ── Charts Row ───────────────────────────────────── */}
<motion.div variants={FADE_UP} className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* CPU / Memory chart */}
<GlassCard className="p-5 lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-white">Resource Utilization</h3>
<p className="text-xs text-white/30">Avg CPU & Memory — last 20 min</p>
</div>
<div className="flex items-center gap-4 text-xs">
<span className="flex items-center gap-1.5 text-cyan-400"><span className="w-2 h-0.5 bg-cyan-400 rounded" />CPU</span>
<span className="flex items-center gap-1.5 text-purple-400"><span className="w-2 h-0.5 bg-purple-400 rounded" />Memory</span>
</div>
</div>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={cpuData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="cpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00d4ff" stopOpacity={0.3} />
<stop offset="95%" stopColor="#00d4ff" stopOpacity={0} />
</linearGradient>
<linearGradient id="mem" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#9d4edd" stopOpacity={0.3} />
<stop offset="95%" stopColor="#9d4edd" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="t" tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }} axisLine={false} tickLine={false} domain={[0, 100]} />
<Tooltip
contentStyle={{ backgroundColor: '#0f1f35', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: 'rgba(255,255,255,0.6)' }}
/>
<Area type="monotone" dataKey="cpu" stroke="#00d4ff" strokeWidth={2} fill="url(#cpu)" />
<Area type="monotone" dataKey="mem" stroke="#9d4edd" strokeWidth={2} fill="url(#mem)" />
</AreaChart>
</ResponsiveContainer>
</GlassCard>
{/* VM Status Donut */}
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-1">VM Status</h3>
<p className="text-xs text-white/30 mb-4">Across all hypervisors</p>
<ResponsiveContainer width="100%" height={140}>
<PieChart>
<Pie data={[
{ name: 'Running', value: stats.vms_running },
{ name: 'Stopped', value: stats.vms_stopped },
{ name: 'Other', value: Math.max(0, stats.vms - stats.vms_running - stats.vms_stopped) },
]}
cx="50%" cy="50%" innerRadius={45} outerRadius={65}
paddingAngle={3} dataKey="value"
>
<Cell fill="#00d4ff" />
<Cell fill="#334155" />
<Cell fill="#9d4edd" />
</Pie>
<Tooltip contentStyle={{ backgroundColor: '#0f1f35', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '12px' }} />
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5 mt-2">
{[{ label: 'Running', val: stats.vms_running, color: 'bg-cyan-400' },
{ label: 'Stopped', val: stats.vms_stopped, color: 'bg-slate-600' }].map(s => (
<div key={s.label} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2"><span className={`w-2 h-2 rounded-full ${s.color}`} /><span className="text-white/50">{s.label}</span></div>
<span className="text-white font-medium">{s.val}</span>
</div>
))}
</div>
</GlassCard>
</motion.div>
{/* ── Hypervisors & Endpoints ───────────────────────── */}
<motion.div variants={FADE_UP} className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Hypervisors */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Hypervisors</h3>
<a href="/hypervisors" className="text-xs text-cyan-400 hover:text-cyan-300">View all →</a>
</div>
<div className="space-y-2">
{hypervisors?.slice(0, 6).map(h => (
<a key={h.id} href={`/hypervisors/${h.id}`}
className="flex items-center gap-3 p-3 rounded-lg bg-white/[0.02] hover:bg-white/[0.05] border border-white/[0.05] transition-colors group"
>
<span className={`w-2 h-2 rounded-full ${getStatusDot(h.status)} shrink-0`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate group-hover:text-cyan-300 transition-colors">{h.name}</p>
<p className="text-xs text-white/30 capitalize">{h.type} · {timeAgo(h.last_sync_at)}</p>
</div>
<StatusBadge status={h.status} />
</a>
))}
{(!hypervisors || hypervisors.length === 0) && (
<p className="text-sm text-white/30 text-center py-6">No hypervisors configured</p>
)}
</div>
</GlassCard>
{/* Docker Endpoints */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Docker Endpoints</h3>
<a href="/containers" className="text-xs text-cyan-400 hover:text-cyan-300">View all →</a>
</div>
<div className="space-y-2">
{endpoints?.slice(0, 6).map(e => (
<div key={e.id} className="flex items-center gap-3 p-3 rounded-lg bg-white/[0.02] border border-white/[0.05]">
<span className={`w-2 h-2 rounded-full ${getStatusDot(e.status)} shrink-0`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{e.name}</p>
<p className="text-xs text-white/30 capitalize">{e.type}</p>
</div>
<StatusBadge status={e.status} />
</div>
))}
{(!endpoints || endpoints.length === 0) && (
<p className="text-sm text-white/30 text-center py-6">No Docker endpoints</p>
)}
</div>
</GlassCard>
</motion.div>
{/* ── Cloud & Alerts ────────────────────────────────── */}
<motion.div variants={FADE_UP} className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cloud accounts */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Cloud Accounts</h3>
<a href="/cloud" className="text-xs text-cyan-400 hover:text-cyan-300">Manage →</a>
</div>
<div className="grid grid-cols-2 gap-2">
{cloud_accounts?.map(acc => (
<div key={acc.id} className="flex items-center gap-3 p-3 rounded-lg bg-white/[0.02] border border-white/[0.05]">
<div className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center text-xs font-bold"
style={{ color: PROVIDER_COLORS[acc.provider] ?? '#fff' }}>
{acc.provider?.toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-white truncate">{acc.name}</p>
<StatusBadge status={acc.status} className="mt-0.5 text-[10px]" />
</div>
</div>
))}
{(!cloud_accounts || cloud_accounts.length === 0) && (
<p className="col-span-2 text-sm text-white/30 text-center py-6">No cloud accounts</p>
)}
</div>
</GlassCard>
{/* Recent Alerts */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Active Alerts</h3>
<a href="/monitoring/alerts" className="text-xs text-cyan-400 hover:text-cyan-300">View all →</a>
</div>
<div className="space-y-2">
{recent_alerts?.map(alert => (
<div key={alert.id} className="flex items-start gap-3 p-3 rounded-lg bg-white/[0.02] border border-white/[0.05]">
<AlertTriangle className={`w-4 h-4 mt-0.5 shrink-0 ${alert.severity === 'critical' ? 'text-red-400' : 'text-yellow-400'}`} />
<div className="min-w-0 flex-1">
<p className="text-sm text-white truncate">{alert.title}</p>
<p className="text-xs text-white/30">{alert.target_name} · {timeAgo(alert.created_at)}</p>
</div>
<StatusBadge status={alert.severity} />
</div>
))}
{(!recent_alerts || recent_alerts.length === 0) && (
<div className="flex flex-col items-center py-6 gap-2">
<Shield className="w-8 h-8 text-green-400/50" />
<p className="text-sm text-white/30">All clear — no active alerts</p>
</div>
)}
</div>
</GlassCard>
</motion.div>
</motion.div>
</AppLayout>
);
}

View File

@@ -0,0 +1,39 @@
import { Head } from '@inertiajs/react';
import { motion } from 'framer-motion';
import { Zap, AlertTriangle } from 'lucide-react';
const MESSAGES = {
403: { title: 'Access Denied', desc: 'You do not have permission to access this resource.' },
404: { title: 'Page Not Found', desc: 'The page you are looking for does not exist.' },
500: { title: 'Server Error', desc: 'Something went wrong on our end. Please try again.' },
503: { title: 'Unavailable', desc: 'The service is temporarily unavailable.' },
};
export default function Error({ status }) {
const { title, desc } = MESSAGES[status] ?? { title: 'Error', desc: 'An unexpected error occurred.' };
return (
<>
<Head title={`${status} — ${title}`} />
<div className="min-h-screen bg-[#050a0e] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-grid-pattern opacity-30" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center relative z-10"
>
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
<AlertTriangle className="w-10 h-10 text-red-400" />
</div>
<p className="text-8xl font-black text-white/10 mb-4">{status}</p>
<h1 className="text-2xl font-bold text-white mb-2">{title}</h1>
<p className="text-white/50 mb-8">{desc}</p>
<a href="/" className="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-cyan-500 text-black font-semibold hover:bg-cyan-400 transition-colors">
<Zap className="w-4 h-4" />
Back to Dashboard
</a>
</motion.div>
</div>
</>
);
}

View File

@@ -0,0 +1,163 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, useForm, router } from '@inertiajs/react';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Server, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { GlassCard } from '@/Components/ui/GlassCard';
import { Button } from '@/Components/ui/Button';
import { cn } from '@/lib/utils';
const TYPES = [
{ value: 'vcenter', label: 'VMware vCenter', port: 443 },
{ value: 'esxi', label: 'VMware ESXi', port: 443 },
{ value: 'proxmox', label: 'Proxmox VE', port: 8006 },
{ value: 'hyper-v', label: 'Hyper-V', port: 5985 },
{ value: 'xen', label: 'XenServer', port: 443 },
{ value: 'kvm', label: 'KVM/QEMU', port: 22 },
];
function Field({ label, error, children }) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-white/60">{label}</label>
{children}
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
);
}
function Input({ className, ...props }) {
return (
<input className={cn(
'w-full px-3 py-2.5 bg-white/5 border border-white/10 rounded-xl text-sm text-white placeholder:text-white/20',
'focus:outline-none focus:border-cyan-500/60 focus:bg-white/[0.07] transition-all',
className
)} {...props} />
);
}
export default function HypervisorCreate() {
const { data, setData, post, processing, errors } = useForm({
name: '', type: 'vcenter', host: '', port: 443,
username: '', password: '', ssl_verify: true, datacenter: '',
});
const [testResult, setTestResult] = useState(null);
const [testing, setTesting] = useState(false);
const handleTypeChange = (type) => {
const t = TYPES.find(t => t.value === type);
setData(d => ({ ...d, type, port: t?.port ?? 443 }));
};
const testConn = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await fetch('/hypervisors/test-temp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
body: JSON.stringify(data),
});
const json = await res.json();
setTestResult(json);
} catch (e) {
setTestResult({ success: false, message: 'Connection failed: ' + e.message });
} finally {
setTesting(false);
}
};
return (
<AppLayout title="Add Hypervisor">
<Head title="Add Hypervisor" />
<div className="max-w-2xl space-y-5">
<div>
<h2 className="text-xl font-bold text-white">Add Hypervisor</h2>
<p className="text-sm text-white/40">Connect a new virtualization platform</p>
</div>
<GlassCard className="p-6">
<form onSubmit={e => { e.preventDefault(); post('/hypervisors'); }} className="space-y-5">
{/* Type selector */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-white/60">Hypervisor Type</label>
<div className="grid grid-cols-3 gap-2">
{TYPES.map(t => (
<button key={t.value} type="button" onClick={() => handleTypeChange(t.value)}
className={cn(
'px-3 py-2 rounded-xl border text-xs font-medium transition-all',
data.type === t.value
? 'border-cyan-500/50 bg-cyan-500/10 text-cyan-400'
: 'border-white/10 bg-white/5 text-white/50 hover:border-white/20 hover:text-white'
)}>
{t.label}
</button>
))}
</div>
</div>
<Field label="Display Name" error={errors.name}>
<Input value={data.name} onChange={e => setData('name', e.target.value)} placeholder="Production vCenter" required />
</Field>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="Hostname / IP" error={errors.host}>
<Input value={data.host} onChange={e => setData('host', e.target.value)} placeholder="vcenter.example.com" required />
</Field>
</div>
<Field label="Port" error={errors.port}>
<Input type="number" value={data.port} onChange={e => setData('port', parseInt(e.target.value))} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Username" error={errors.username}>
<Input value={data.username} onChange={e => setData('username', e.target.value)} placeholder="administrator@vsphere.local" required />
</Field>
<Field label="Password" error={errors.password}>
<Input type="password" value={data.password} onChange={e => setData('password', e.target.value)} required />
</Field>
</div>
<Field label="Datacenter (optional)" error={errors.datacenter}>
<Input value={data.datacenter} onChange={e => setData('datacenter', e.target.value)} placeholder="Datacenter name" />
</Field>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={data.ssl_verify} onChange={e => setData('ssl_verify', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-0" />
<span className="text-sm text-white/70">Verify SSL certificate</span>
</label>
{/* Test Result */}
{testResult && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}
className={cn(
'flex items-center gap-3 p-3 rounded-xl border text-sm',
testResult.success
? 'bg-green-500/10 border-green-500/30 text-green-300'
: 'bg-red-500/10 border-red-500/30 text-red-300'
)}>
{testResult.success
? <CheckCircle className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />}
{testResult.message}
</motion.div>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="secondary" onClick={testConn} loading={testing}>
{!testing && 'Test Connection'}
</Button>
<div className="flex items-center gap-3">
<a href="/hypervisors"><Button type="button" variant="ghost">Cancel</Button></a>
<Button type="submit" loading={processing}>Add Hypervisor</Button>
</div>
</div>
</form>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,114 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { motion } from 'framer-motion';
import { Plus, RefreshCw, Server, TestTube, Edit, Trash2, Eye } from 'lucide-react';
import { GlassCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { timeAgo } from '@/lib/utils';
import { useState } from 'react';
const TYPE_LABELS = {
vcenter: 'VMware vCenter', esxi: 'VMware ESXi', proxmox: 'Proxmox VE',
'hyper-v': 'Hyper-V', xen: 'XenServer', kvm: 'KVM',
};
const TYPE_COLORS = {
vcenter: 'text-blue-400', esxi: 'text-blue-300', proxmox: 'text-orange-400',
'hyper-v': 'text-blue-500', kvm: 'text-green-400',
};
export default function HypervisorIndex({ hypervisors }) {
const [syncing, setSyncing] = useState(null);
const sync = (id) => {
setSyncing(id);
router.post(`/hypervisors/${id}/sync`, {}, {
onFinish: () => setSyncing(null),
});
};
const columns = [
{ header: 'Name', accessor: 'name', render: row => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center">
<Server className="w-4 h-4 text-white/50" />
</div>
<div>
<p className="font-medium text-white">{row.name}</p>
<p className={`text-xs ${TYPE_COLORS[row.type] ?? 'text-white/40'}`}>{TYPE_LABELS[row.type] ?? row.type}</p>
</div>
</div>
)},
{ header: 'Host', accessor: 'host', render: row => (
<span className="font-mono text-xs text-white/60">{row.host}:{row.port}</span>
)},
{ header: 'VMs', key: 'vms', render: row => (
<span className="text-white font-medium">{row.virtual_machines_count ?? 0}</span>
)},
{ header: 'Status', accessor: 'status', render: row => <StatusBadge status={row.status} /> },
{ header: 'Last Sync', accessor: 'last_sync_at', render: row => (
<span className="text-xs text-white/40">{timeAgo(row.last_sync_at)}</span>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<button onClick={() => sync(row.id)} disabled={syncing === row.id}
className="p-1.5 rounded-lg hover:bg-white/10 text-white/40 hover:text-cyan-400 transition-colors"
title="Sync">
<RefreshCw className={`w-3.5 h-3.5 ${syncing === row.id ? 'animate-spin' : ''}`} />
</button>
<a href={`/hypervisors/${row.id}`} className="p-1.5 rounded-lg hover:bg-white/10 text-white/40 hover:text-white transition-colors" title="View">
<Eye className="w-3.5 h-3.5" />
</a>
<a href={`/hypervisors/${row.id}/edit`} className="p-1.5 rounded-lg hover:bg-white/10 text-white/40 hover:text-white transition-colors" title="Edit">
<Edit className="w-3.5 h-3.5" />
</a>
<button onClick={() => { if (confirm('Remove this hypervisor?')) router.delete(`/hypervisors/${row.id}`); }}
className="p-1.5 rounded-lg hover:bg-red-500/20 text-white/40 hover:text-red-400 transition-colors" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)},
];
return (
<AppLayout title="Hypervisors">
<Head title="Hypervisors" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Hypervisors</h2>
<p className="text-sm text-white/40">Manage vCenter, Proxmox, ESXi, Hyper-V and more</p>
</div>
<a href="/hypervisors/create">
<Button icon={Plus}>Add Hypervisor</Button>
</a>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Total', value: hypervisors.total ?? 0, color: 'text-white' },
{ label: 'Online', value: hypervisors.data?.filter(h => h.status === 'online').length ?? 0, color: 'text-green-400' },
{ label: 'Offline', value: hypervisors.data?.filter(h => h.status === 'offline').length ?? 0, color: 'text-red-400' },
{ label: 'Error', value: hypervisors.data?.filter(h => h.status === 'error').length ?? 0, color: 'text-orange-400' },
].map(s => (
<GlassCard key={s.label} className="p-4 text-center">
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
<p className="text-xs text-white/40 mt-0.5">{s.label}</p>
</GlassCard>
))}
</div>
<GlassCard className="p-5">
<DataTable
columns={columns}
data={hypervisors.data ?? []}
onRowClick={row => router.visit(`/hypervisors/${row.id}`)}
emptyMessage="No hypervisors configured. Add your first one!"
/>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,161 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head } from '@inertiajs/react';
import { KeyRound, Lock, Certificate, Key, Plus, Eye, EyeOff, Copy, AlertCircle } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { timeAgo } from '@/lib/utils';
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
export default function IdentityIndex({ passwords, certificates, ssh_keys, stats }) {
const [activeTab, setActiveTab] = useState('passwords');
const passwordColumns = [
{ header: 'Title', accessor: 'title', render: row => (
<div>
<p className="text-sm text-white font-medium">{row.title}</p>
{row.url && <p className="text-xs text-white/30 truncate max-w-48">{row.url}</p>}
</div>
)},
{ header: 'Username', accessor: 'username', render: row => (
<span className="font-mono text-xs text-white/60">{row.username ?? '—'}</span>
)},
{ header: 'Category', accessor: 'category', render: row => (
row.category ? <span className="text-xs px-2 py-0.5 rounded bg-white/5 text-white/60">{row.category}</span> : null
)},
{ header: 'Expires', accessor: 'expires_at', render: row => (
row.expires_at
? <span className={`text-xs ${new Date(row.expires_at) < new Date() ? 'text-red-400' : 'text-white/40'}`}>{timeAgo(row.expires_at)}</span>
: <span className="text-xs text-white/20">Never</span>
)},
{ header: '', key: 'actions', render: row => (
<div className="flex gap-1">
<Button size="sm" variant="ghost" icon={Eye} onClick={() => {}}>Reveal</Button>
<Button size="sm" variant="ghost" icon={Copy} onClick={() => {}}>Copy</Button>
</div>
)},
];
const certColumns = [
{ header: 'Domain', accessor: 'domain', render: row => (
<div>
<p className="text-sm text-white font-medium font-mono">{row.domain}</p>
<p className="text-xs text-white/30">{row.issuer ?? 'Self-signed'}</p>
</div>
)},
{ header: 'Type', accessor: 'type', render: row => (
<span className="text-xs px-2 py-0.5 rounded bg-white/5 text-white/60 capitalize">{row.type?.replace('-', ' ')}</span>
)},
{ header: 'Expires', accessor: 'expires_at', render: row => {
const daysLeft = Math.ceil((new Date(row.expires_at) - new Date()) / 86400000);
return (
<div>
<p className={`text-sm font-medium ${daysLeft < 30 ? 'text-red-400' : daysLeft < 60 ? 'text-yellow-400' : 'text-white/70'}`}>
{daysLeft > 0 ? `${daysLeft}d left` : 'Expired'}
</p>
<p className="text-xs text-white/30">{new Date(row.expires_at).toLocaleDateString()}</p>
</div>
);
}},
{ header: 'Status', accessor: 'status', render: row => <StatusBadge status={row.status} /> },
{ header: '', key: 'actions', render: row => (
<Button size="sm" variant="secondary">Download</Button>
)},
];
return (
<AppLayout title="Identity & Secrets">
<Head title="Identity & Secrets" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Identity & Secrets</h2>
<p className="text-sm text-white/40">Passwords, certificates, SSH keys, and secrets</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard title="Passwords" value={stats?.passwords ?? 0} icon={Lock} color="cyan" />
<StatCard title="Certificates" value={stats?.certs ?? 0} icon={KeyRound} color="green" />
<StatCard title="Expiring Soon" value={stats?.expiring ?? 0} icon={AlertCircle} color="red" />
<StatCard title="SSH Keys" value={stats?.ssh_keys ?? 0} icon={Key} color="purple" />
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="flex gap-1 p-1 bg-white/5 rounded-xl w-fit border border-white/10 mb-4">
{[
{ value: 'passwords', label: 'Passwords', icon: Lock },
{ value: 'certificates', label: 'Certificates', icon: KeyRound },
{ value: 'ssh-keys', label: 'SSH Keys', icon: Key },
].map(t => {
const Icon = t.icon;
return (
<TabsTrigger key={t.value} value={t.value}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
activeTab === t.value
? 'bg-white/10 text-white'
: 'text-white/50 hover:text-white'
)}>
<Icon className="w-3.5 h-3.5" />
{t.label}
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="passwords">
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Password Vault</h3>
<Button icon={Plus} size="sm">Add Password</Button>
</div>
<DataTable columns={passwordColumns} data={passwords ?? []}
emptyMessage="No passwords stored. Add your first entry." />
</GlassCard>
</TabsContent>
<TabsContent value="certificates">
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">SSL/TLS Certificates</h3>
<div className="flex gap-2">
<Button variant="secondary" size="sm">Generate Self-Signed</Button>
<Button icon={Plus} size="sm">Import Cert</Button>
</div>
</div>
<DataTable columns={certColumns} data={certificates ?? []}
emptyMessage="No certificates. Import or generate your first certificate." />
</GlassCard>
</TabsContent>
<TabsContent value="ssh-keys">
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">SSH Keys</h3>
<div className="flex gap-2">
<Button variant="secondary" size="sm">Generate Key Pair</Button>
<Button icon={Plus} size="sm">Import Key</Button>
</div>
</div>
<DataTable
columns={[
{ header: 'Name', accessor: 'name', render: r => <p className="text-sm text-white">{r.name}</p> },
{ header: 'Algorithm', accessor: 'algorithm', render: r => <span className="font-mono text-xs text-white/60">{r.algorithm?.toUpperCase()} {r.key_size}</span> },
{ header: 'Fingerprint', accessor: 'fingerprint', render: r => <span className="font-mono text-xs text-white/40 truncate max-w-40 block">{r.fingerprint}</span> },
{ header: '', key: 'a', render: r => <Button size="sm" variant="ghost" icon={Copy}>Copy Public Key</Button> },
]}
data={ssh_keys ?? []}
emptyMessage="No SSH keys. Generate or import a key pair."
/>
</GlassCard>
</TabsContent>
</Tabs>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,131 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head } from '@inertiajs/react';
import { motion } from 'framer-motion';
import { Activity, Bell, CheckCircle, AlertTriangle, XCircle, Plus } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { DataTable } from '@/Components/ui/DataTable';
import { ProgressBar } from '@/Components/ui/ProgressBar';
import { timeAgo } from '@/lib/utils';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, AreaChart, Area
} from 'recharts';
// Demo metric data
const metricStream = Array.from({ length: 30 }, (_, i) => ({
t: `-${30 - i}m`,
cpu: 30 + Math.sin(i * 0.3) * 20 + Math.random() * 10,
mem: 55 + Math.cos(i * 0.2) * 15 + Math.random() * 8,
net: Math.abs(Math.sin(i * 0.5) * 400 + Math.random() * 100),
}));
export default function MonitoringIndex({ alerts, alert_stats, dashboards }) {
const firing = alerts?.filter(a => a.status === 'firing') ?? [];
const acked = alerts?.filter(a => a.status === 'acknowledged') ?? [];
const alertColumns = [
{ header: 'Alert', accessor: 'title', render: row => (
<div>
<p className="text-sm text-white">{row.title}</p>
<p className="text-xs text-white/30">{row.target_name}</p>
</div>
)},
{ header: 'Severity', accessor: 'severity', render: row => (
<StatusBadge status={row.severity} />
)},
{ header: 'Status', accessor: 'status', render: row => (
<StatusBadge status={row.status} />
)},
{ header: 'Fired', accessor: 'created_at', render: row => (
<span className="text-xs text-white/40">{timeAgo(row.created_at)}</span>
)},
{ header: '', key: 'actions', render: row => (
row.status === 'firing' && (
<Button size="sm" variant="secondary" onClick={() => {}}>Acknowledge</Button>
)
)},
];
return (
<AppLayout title="Monitoring">
<Head title="Monitoring" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Unified Monitoring</h2>
<p className="text-sm text-white/40">Real-time metrics, alerts, and dashboards</p>
</div>
<div className="flex gap-2">
<a href="/monitoring/dashboards"><Button variant="secondary">Dashboards</Button></a>
<a href="/monitoring/alerts"><Button variant="secondary" icon={Bell}>Alert Rules</Button></a>
</div>
</div>
{/* Alert Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard title="Firing" value={firing.length} color="red" icon={XCircle} />
<StatCard title="Acknowledged" value={acked.length} color="yellow" icon={Bell} />
<StatCard title="Resolved 24h" value={alert_stats?.resolved_24h ?? 0} color="green" icon={CheckCircle} />
<StatCard title="Total Rules" value={alert_stats?.total_rules ?? 0} color="blue" icon={Activity} />
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-1">CPU & Memory</h3>
<p className="text-xs text-white/30 mb-4">Average across all VMs — last 30 min</p>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={metricStream} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="cpu2" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00d4ff" stopOpacity={0.3} />
<stop offset="95%" stopColor="#00d4ff" stopOpacity={0} />
</linearGradient>
<linearGradient id="mem2" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#9d4edd" stopOpacity={0.3} />
<stop offset="95%" stopColor="#9d4edd" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="t" tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }} axisLine={false} tickLine={false} interval={5} />
<YAxis tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }} axisLine={false} tickLine={false} domain={[0, 100]} />
<Tooltip contentStyle={{ backgroundColor: '#0f1f35', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '12px' }} />
<Area type="monotone" dataKey="cpu" stroke="#00d4ff" fill="url(#cpu2)" strokeWidth={1.5} name="CPU %" />
<Area type="monotone" dataKey="mem" stroke="#9d4edd" fill="url(#mem2)" strokeWidth={1.5} name="Mem %" />
</AreaChart>
</ResponsiveContainer>
</GlassCard>
<GlassCard className="p-5">
<h3 className="text-sm font-semibold text-white mb-1">Network I/O</h3>
<p className="text-xs text-white/30 mb-4">Mbps across all endpoints</p>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={metricStream} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="t" tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }} axisLine={false} tickLine={false} interval={5} />
<YAxis tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#0f1f35', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '12px' }} />
<Line type="monotone" dataKey="net" stroke="#00ff88" strokeWidth={1.5} dot={false} name="Net Mbps" />
</LineChart>
</ResponsiveContainer>
</GlassCard>
</div>
{/* Active Alerts Table */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Active Alerts</h3>
<Button icon={Plus} size="sm">Create Rule</Button>
</div>
<DataTable
columns={alertColumns}
data={alerts ?? []}
emptyMessage="No active alerts — all systems operational"
/>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,138 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { motion } from 'framer-motion';
import { Shield, AlertTriangle, CheckCircle, TrendingUp, Scan, Lock, Bug } from 'lucide-react';
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
import { StatusBadge } from '@/Components/ui/StatusBadge';
import { Button } from '@/Components/ui/Button';
import { ProgressBar } from '@/Components/ui/ProgressBar';
import { DataTable } from '@/Components/ui/DataTable';
import { timeAgo, getSecurityGradeColor } from '@/lib/utils';
import { RadarChart, Radar, PolarGrid, PolarAngleAxis, ResponsiveContainer } from 'recharts';
import { cn } from '@/lib/utils';
const radarData = [
{ subject: 'Patching', A: 85 },
{ subject: 'Access Ctrl', A: 72 },
{ subject: 'Encryption', A: 90 },
{ subject: 'Network', A: 65 },
{ subject: 'Monitoring', A: 78 },
{ subject: 'Compliance', A: 60 },
];
function ScoreRing({ score, grade }) {
const color = grade === 'A' ? '#00ff88' : grade === 'B' ? '#00d4ff' : grade === 'C' ? '#ffd700' : grade === 'D' ? '#ff6b35' : '#ff2d55';
const r = 60, circumference = 2 * Math.PI * r;
const dash = (score / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="w-40 h-40 -rotate-90">
<circle cx="80" cy="80" r={r} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="8" />
<circle cx="80" cy="80" r={r} fill="none" stroke={color} strokeWidth="8"
strokeDasharray={`${dash} ${circumference}`}
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 8px ${color}60)` }}
/>
</svg>
<div className="absolute text-center">
<p className="text-4xl font-bold" style={{ color }}>{score}</p>
<p className="text-xs text-white/40">/ 100</p>
</div>
</div>
);
}
export default function SecurityIndex({ scores, scans, stats }) {
const avgScore = scores?.avg ?? 72;
const grade = avgScore >= 90 ? 'A' : avgScore >= 75 ? 'B' : avgScore >= 60 ? 'C' : avgScore >= 45 ? 'D' : 'F';
const scanColumns = [
{ header: 'Target', accessor: 'target_name', render: row => (
<div>
<p className="text-sm text-white">{row.target_name}</p>
<p className="text-xs text-white/30 capitalize">{row.target_type} · {row.scan_type}</p>
</div>
)},
{ header: 'Score', accessor: 'score', render: row => (
<div className="flex items-center gap-2">
<span className={`text-lg font-bold ${getSecurityGradeColor(row.grade)}`}>{row.grade}</span>
<span className="text-sm text-white/60">{row.score ?? '—'}</span>
</div>
)},
{ header: 'Findings', key: 'findings', render: row => (
<div className="flex items-center gap-1 text-xs">
{row.critical_count > 0 && <span className="px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">{row.critical_count}C</span>}
{row.high_count > 0 && <span className="px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400">{row.high_count}H</span>}
{row.medium_count > 0 && <span className="px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">{row.medium_count}M</span>}
{row.low_count > 0 && <span className="px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">{row.low_count}L</span>}
</div>
)},
{ header: 'Status', accessor: 'status', render: row => <StatusBadge status={row.status} /> },
{ header: 'Scanned', accessor: 'completed_at', render: row => (
<span className="text-xs text-white/40">{timeAgo(row.completed_at)}</span>
)},
];
return (
<AppLayout title="Security">
<Head title="Security Dashboard" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Cybersecurity Dashboard</h2>
<p className="text-sm text-white/40">Security posture across all infrastructure</p>
</div>
<div className="flex gap-2">
<a href="/security/scans"><Button variant="secondary" icon={Scan}>Scans</Button></a>
<a href="/security/compliance"><Button variant="secondary" icon={CheckCircle}>Compliance</Button></a>
</div>
</div>
{/* Score + Radar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<GlassCard className="p-6 flex flex-col items-center justify-center gap-3" glow="green">
<h3 className="text-sm font-semibold text-white/60">Overall Security Score</h3>
<ScoreRing score={avgScore} grade={grade} />
<div className="flex items-center gap-2">
<span className={cn('text-3xl font-bold', getSecurityGradeColor(grade))}>Grade {grade}</span>
</div>
<p className="text-xs text-white/30 text-center">Based on {scans?.total ?? 0} scans across infrastructure</p>
</GlassCard>
<GlassCard className="p-5 lg:col-span-2">
<h3 className="text-sm font-semibold text-white mb-1">Security Radar</h3>
<p className="text-xs text-white/30 mb-3">Coverage across security domains</p>
<ResponsiveContainer width="100%" height={220}>
<RadarChart cx="50%" cy="50%" outerRadius={80} data={radarData}>
<PolarGrid stroke="rgba(255,255,255,0.1)" />
<PolarAngleAxis dataKey="subject" tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 11 }} />
<Radar name="Score" dataKey="A" stroke="#00d4ff" fill="#00d4ff" fillOpacity={0.15} strokeWidth={2} />
</RadarChart>
</ResponsiveContainer>
</GlassCard>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard title="Critical" value={stats?.critical ?? 0} color="red" icon={AlertTriangle} />
<StatCard title="High" value={stats?.high ?? 0} color="orange" icon={Bug} />
<StatCard title="Medium" value={stats?.medium ?? 0} color="orange" />
<StatCard title="Low" value={stats?.low ?? 0} color="blue" />
</div>
{/* Scan results table */}
<GlassCard className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Recent Scans</h3>
<Button icon={Scan} size="sm" onClick={() => router.post('/security/scans')}>Run Scan</Button>
</div>
<DataTable
columns={scanColumns}
data={scans?.data ?? []}
emptyMessage="No scans yet. Run your first security scan."
/>
</GlassCard>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,113 @@
import AppLayout from '@/Layouts/AppLayout';
import { Head, router } from '@inertiajs/react';
import { BookOpen, Plus, FileText, Folder, Upload, Search } from 'lucide-react';
import { GlassCard } from '@/Components/ui/GlassCard';
import { Button } from '@/Components/ui/Button';
import { motion } from 'framer-motion';
import { timeAgo } from '@/lib/utils';
import { useState } from 'react';
export default function WikiIndex({ spaces, recent_pages, files }) {
const [search, setSearch] = useState('');
const filtered = (recent_pages ?? []).filter(p =>
p.title.toLowerCase().includes(search.toLowerCase())
);
return (
<AppLayout title="Wiki">
<Head title="Wiki & Documentation" />
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Wiki & Documents</h2>
<p className="text-sm text-white/40">Centralized documentation, runbooks, and files</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" icon={Upload}>Upload File</Button>
<a href="/wiki/pages/create"><Button icon={Plus}>New Page</Button></a>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-white/30" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search pages, documents..."
className="w-full pl-12 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-sm text-white placeholder:text-white/20 focus:outline-none focus:border-cyan-500/50 transition-all"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Spaces */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Spaces</h3>
<Button size="sm" variant="ghost" icon={Plus}>New Space</Button>
</div>
<div className="space-y-2">
{(spaces ?? []).map(space => (
<a key={space.id} href={`/wiki/spaces/${space.slug}`}>
<GlassCard className="p-4 cursor-pointer" hover>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center text-lg"
style={{ backgroundColor: `${space.color}20`, color: space.color }}>
<Folder className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-white">{space.name}</p>
<p className="text-xs text-white/30">{space.pages_count ?? 0} pages</p>
</div>
</div>
</GlassCard>
</a>
))}
{(!spaces || spaces.length === 0) && (
<GlassCard className="p-6 text-center">
<BookOpen className="w-8 h-8 text-white/20 mx-auto mb-2" />
<p className="text-sm text-white/30">No spaces yet</p>
</GlassCard>
)}
</div>
</div>
{/* Recent Pages */}
<div className="lg:col-span-2 space-y-3">
<h3 className="text-sm font-semibold text-white">
{search ? `Search: "${search}"` : 'Recent Pages'}
</h3>
<div className="space-y-2">
{filtered.map(page => (
<a key={page.id} href={`/wiki/pages/${page.id}`}>
<GlassCard className="p-4 cursor-pointer" hover>
<div className="flex items-start gap-3">
<FileText className="w-4 h-4 text-white/30 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{page.title}</p>
<div className="flex items-center gap-3 mt-1">
<p className="text-xs text-white/30">{page.space?.name}</p>
<span className="text-white/10">·</span>
<p className="text-xs text-white/30">Updated {timeAgo(page.updated_at)}</p>
</div>
</div>
</div>
</GlassCard>
</a>
))}
{filtered.length === 0 && (
<GlassCard className="p-8 text-center">
<FileText className="w-8 h-8 text-white/20 mx-auto mb-2" />
<p className="text-sm text-white/30">
{search ? 'No pages match your search' : 'No pages yet. Create your first page!'}
</p>
</GlassCard>
)}
</div>
</div>
</div>
</div>
</AppLayout>
);
}

24
resources/js/app.jsx Normal file
View File

@@ -0,0 +1,24 @@
import './bootstrap';
import '../css/app.css';
import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
createInertiaApp({
title: (title) => `${title} — Infra Manager`,
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.jsx`,
import.meta.glob('./Pages/**/*.jsx')
),
setup({ el, App, props }) {
const root = createRoot(el);
root.render(<App {...props} />);
},
progress: {
color: '#00d4ff',
includeCSS: true,
showSpinner: true,
},
});

4
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

71
resources/js/lib/utils.js Normal file
View File

@@ -0,0 +1,71 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes, decimals = 2) {
if (!bytes || bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function getStatusColor(status) {
const map = {
online: 'text-green-400',
running: 'text-cyan-400',
active: 'text-green-400',
stopped: 'text-slate-400',
offline: 'text-red-400',
error: 'text-red-400',
warning: 'text-yellow-400',
unknown: 'text-gray-400',
paused: 'text-yellow-400',
exited: 'text-slate-400',
};
return map[status?.toLowerCase()] ?? 'text-gray-400';
}
export function getStatusDot(status) {
const map = {
online: 'bg-green-400',
running: 'bg-cyan-400',
active: 'bg-green-400',
stopped: 'bg-slate-500',
offline: 'bg-red-400',
error: 'bg-red-400',
warning: 'bg-yellow-400',
unknown: 'bg-gray-500',
paused: 'bg-yellow-400',
exited: 'bg-slate-500',
};
return map[status?.toLowerCase()] ?? 'bg-gray-500';
}
export function getSecurityGradeColor(grade) {
const map = { A: 'text-green-400', B: 'text-cyan-400', C: 'text-yellow-400', D: 'text-orange-400', F: 'text-red-400' };
return map[grade] ?? 'text-gray-400';
}
export function timeAgo(date) {
if (!date) return 'Never';
const d = new Date(date);
const seconds = Math.floor((new Date() - d) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title inertia>{{ config('app.name', 'Infra Manager') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Scripts -->
@routes
@viteReactRefresh
@vite(['resources/js/app.jsx', 'resources/css/app.css'])
@inertiaHead
</head>
<body class="font-sans antialiased bg-[#050a0e] text-white">
@inertia
</body>
</html>