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:
120
resources/js/Pages/Acl/Index.jsx
Normal file
120
resources/js/Pages/Acl/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
resources/js/Pages/Admin/Index.jsx
Normal file
93
resources/js/Pages/Admin/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
resources/js/Pages/Admin/Ldap.jsx
Normal file
146
resources/js/Pages/Admin/Ldap.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
resources/js/Pages/Admin/Settings.jsx
Normal file
115
resources/js/Pages/Admin/Settings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
resources/js/Pages/Auth/Login.jsx
Normal file
154
resources/js/Pages/Auth/Login.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
resources/js/Pages/Backup/Index.jsx
Normal file
114
resources/js/Pages/Backup/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
resources/js/Pages/Cloud/Index.jsx
Normal file
150
resources/js/Pages/Cloud/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
resources/js/Pages/Container/Index.jsx
Normal file
131
resources/js/Pages/Container/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
resources/js/Pages/Dashboard/Index.jsx
Normal file
238
resources/js/Pages/Dashboard/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
resources/js/Pages/Error.jsx
Normal file
39
resources/js/Pages/Error.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
resources/js/Pages/Hypervisor/Create.jsx
Normal file
163
resources/js/Pages/Hypervisor/Create.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
resources/js/Pages/Hypervisor/Index.jsx
Normal file
114
resources/js/Pages/Hypervisor/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
resources/js/Pages/Identity/Index.jsx
Normal file
161
resources/js/Pages/Identity/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
resources/js/Pages/Monitoring/Index.jsx
Normal file
131
resources/js/Pages/Monitoring/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
resources/js/Pages/Security/Index.jsx
Normal file
138
resources/js/Pages/Security/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
resources/js/Pages/Wiki/Index.jsx
Normal file
113
resources/js/Pages/Wiki/Index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user