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

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>
);
}