commit c649f055d831cdbaf1bf92d789fdd2feb3baf059
Author: I-Fun
Date: Tue Apr 7 20:29:32 2026 +0700
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.
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a677acd
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,69 @@
+APP_NAME="Infra Manager"
+APP_ENV=local
+APP_KEY=
+APP_DEBUG=true
+APP_VERSION=1.0.0
+APP_URL=http://localhost:8080
+APP_PORT=8080
+APP_HTTPS_PORT=8443
+
+LOG_CHANNEL=stack
+LOG_DEPRECATIONS_CHANNEL=null
+LOG_LEVEL=debug
+
+# ─── Database ─────────────────────────────────────────────────
+# SQLite (default — ready to use, no setup needed)
+DB_CONNECTION=sqlite
+DB_DATABASE=/var/www/html/database/database.sqlite
+
+# MySQL (uncomment to switch — run: docker compose --profile mysql up)
+# DB_CONNECTION=mysql
+# DB_HOST=mysql
+# DB_PORT=3306
+# DB_DATABASE=infra_manager
+# DB_USERNAME=infra_user
+# DB_PASSWORD=infra_secret
+
+# ─── Redis ────────────────────────────────────────────────────
+REDIS_HOST=redis
+REDIS_PASSWORD=infra_redis_secret
+REDIS_PORT=6379
+
+# ─── Cache & Queue ────────────────────────────────────────────
+CACHE_STORE=redis
+QUEUE_CONNECTION=redis
+SESSION_DRIVER=redis
+SESSION_LIFETIME=480
+
+# ─── Mail ─────────────────────────────────────────────────────
+MAIL_MAILER=log
+MAIL_HOST=mailpit
+MAIL_PORT=1025
+MAIL_USERNAME=null
+MAIL_PASSWORD=null
+MAIL_ENCRYPTION=null
+MAIL_FROM_ADDRESS="noreply@infra.local"
+MAIL_FROM_NAME="${APP_NAME}"
+
+# ─── Security ─────────────────────────────────────────────────
+# Generate with: php artisan key:generate
+APP_KEY=
+
+# ─── Docker Config ────────────────────────────────────────────
+USER_ID=1000
+GROUP_ID=1000
+MYSQL_ROOT_PASSWORD=root_secret_change_me
+MYSQL_DATABASE=infra_manager
+MYSQL_USER=infra_user
+MYSQL_PASSWORD=infra_secret_change_me
+
+# ─── LDAP (optional) ─────────────────────────────────────────
+LDAP_HOST=
+LDAP_PORT=389
+LDAP_BASE_DN=
+LDAP_USERNAME=
+LDAP_PASSWORD=
+LDAP_AUTH_ENABLED=false
+
+# ─── Vite ─────────────────────────────────────────────────────
+VITE_APP_NAME="${APP_NAME}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ee1ae57
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,20 @@
+/node_modules
+/public/build
+/public/hot
+/public/storage
+/storage/*.key
+/vendor
+.env
+.env.backup
+.phpunit.result.cache
+database/database.sqlite
+Homestead.json
+Homestead.yaml
+auth.json
+npm-debug.log
+yarn-error.log
+/.fleet
+/.idea
+/.vscode
+docker/nginx/ssl/
+/bootstrap/cache/*.php
diff --git a/app/Http/Controllers/Acl/RoleController.php b/app/Http/Controllers/Acl/RoleController.php
new file mode 100644
index 0000000..6325368
--- /dev/null
+++ b/app/Http/Controllers/Acl/RoleController.php
@@ -0,0 +1,58 @@
+ \App\Models\User::with('roles')->paginate(30),
+ 'roles' => Role::withCount('permissions', 'users')->get(),
+ ]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Acl/RoleCreate', ['permissions' => Permission::all()->groupBy(fn($p) => explode('.', $p->name)[0])]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate(['name' => 'required|string|unique:roles', 'permissions' => 'array']);
+ $role = Role::create(['name' => $validated['name']]);
+ if ($validated['permissions'] ?? false) {
+ $role->syncPermissions($validated['permissions']);
+ }
+ return redirect()->route('acl.index')->with('success', 'Role created.');
+ }
+
+ public function edit(Role $role)
+ {
+ return Inertia::render('Acl/RoleEdit', [
+ 'role' => $role->load('permissions'),
+ 'permissions' => Permission::all()->groupBy(fn($p) => explode('.', $p->name)[0]),
+ ]);
+ }
+
+ public function update(Request $request, Role $role)
+ {
+ $role->syncPermissions($request->input('permissions', []));
+ return back()->with('success', 'Role updated.');
+ }
+
+ public function destroy(Role $role)
+ {
+ if (in_array($role->name, ['super-admin', 'admin'])) {
+ return back()->with('error', 'Cannot delete system roles.');
+ }
+ $role->delete();
+ return back()->with('success', 'Role deleted.');
+ }
+}
diff --git a/app/Http/Controllers/Acl/UserController.php b/app/Http/Controllers/Acl/UserController.php
new file mode 100644
index 0000000..bff1b6d
--- /dev/null
+++ b/app/Http/Controllers/Acl/UserController.php
@@ -0,0 +1,93 @@
+route('acl.index');
+ }
+
+ public function create()
+ {
+ return \Inertia\Inertia::render('Acl/UserCreate', ['roles' => Role::all()]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string',
+ 'email' => 'required|email|unique:users',
+ 'password' => 'required|string|min:8',
+ 'roles' => 'array',
+ ]);
+
+ $user = User::create([
+ 'name' => $validated['name'],
+ 'email' => $validated['email'],
+ 'password' => Hash::make($validated['password']),
+ ]);
+
+ if ($validated['roles'] ?? false) {
+ $user->assignRole($validated['roles']);
+ }
+
+ return redirect()->route('acl.index')->with('success', 'User created.');
+ }
+
+ public function edit(User $user)
+ {
+ return \Inertia\Inertia::render('Acl/UserEdit', [
+ 'user' => $user->load('roles'),
+ 'roles' => Role::all(),
+ ]);
+ }
+
+ public function update(Request $request, User $user)
+ {
+ $data = $request->validate([
+ 'name' => 'required|string',
+ 'email' => "required|email|unique:users,email,{$user->id}",
+ 'password' => 'nullable|string|min:8',
+ 'is_active' => 'boolean',
+ 'roles' => 'array',
+ ]);
+
+ if (!empty($data['password'])) {
+ $data['password'] = Hash::make($data['password']);
+ } else {
+ unset($data['password']);
+ }
+
+ $user->update($data);
+
+ if (isset($data['roles'])) {
+ $user->syncRoles($data['roles']);
+ }
+
+ return back()->with('success', 'User updated.');
+ }
+
+ public function destroy(User $user)
+ {
+ if ($user->id === auth()->id()) {
+ return back()->with('error', 'Cannot delete your own account.');
+ }
+ $user->delete();
+ return back()->with('success', 'User deleted.');
+ }
+
+ public function assignRole(Request $request, User $user)
+ {
+ $roles = $request->validate(['roles' => 'required|array'])['roles'];
+ $user->syncRoles($roles);
+ return back()->with('success', 'Roles updated.');
+ }
+}
diff --git a/app/Http/Controllers/Admin/AdminController.php b/app/Http/Controllers/Admin/AdminController.php
new file mode 100644
index 0000000..015ae7f
--- /dev/null
+++ b/app/Http/Controllers/Admin/AdminController.php
@@ -0,0 +1,108 @@
+ [
+ 'users' => User::count(),
+ 'sessions' => DB::table('sessions')->count(),
+ 'queued' => DB::table('jobs')->count(),
+ 'db_size' => $this->dbSize(),
+ ],
+ 'recent_activity' => Activity::with('causer')
+ ->orderByDesc('created_at')
+ ->limit(10)
+ ->get(),
+ ]);
+ }
+
+ public function auditLog()
+ {
+ return Inertia::render('Admin/AuditLog', [
+ 'logs' => Activity::with('causer', 'subject')
+ ->orderByDesc('created_at')
+ ->paginate(50),
+ ]);
+ }
+
+ public function systemHealth()
+ {
+ return Inertia::render('Admin/SystemHealth', [
+ 'health' => [
+ 'database' => $this->checkDatabase(),
+ 'cache' => $this->checkCache(),
+ 'queue' => $this->checkQueue(),
+ 'storage' => $this->checkStorage(),
+ ],
+ ]);
+ }
+
+ private function checkDatabase(): array
+ {
+ try {
+ DB::connection()->getPdo();
+ return ['status' => 'ok', 'driver' => config('database.default')];
+ } catch (\Throwable $e) {
+ return ['status' => 'error', 'message' => $e->getMessage()];
+ }
+ }
+
+ private function checkCache(): array
+ {
+ try {
+ cache()->set('health_check', true, 1);
+ return ['status' => 'ok', 'driver' => config('cache.default')];
+ } catch (\Throwable $e) {
+ return ['status' => 'error', 'message' => $e->getMessage()];
+ }
+ }
+
+ private function checkQueue(): array
+ {
+ return [
+ 'status' => 'ok',
+ 'pending' => DB::table('jobs')->count(),
+ 'failed' => DB::table('failed_jobs')->count(),
+ ];
+ }
+
+ private function checkStorage(): array
+ {
+ $path = storage_path();
+ return [
+ 'status' => 'ok',
+ 'writable' => is_writable($path),
+ 'free_bytes' => disk_free_space($path),
+ ];
+ }
+
+ private function dbSize(): string
+ {
+ try {
+ if (config('database.default') === 'sqlite') {
+ $size = filesize(database_path('database.sqlite'));
+ return $this->formatBytes($size);
+ }
+ return '—';
+ } catch (\Throwable) {
+ return '—';
+ }
+ }
+
+ private function formatBytes(int $bytes): string
+ {
+ $units = ['B', 'KB', 'MB', 'GB'];
+ $i = floor(log($bytes, 1024));
+ return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
+ }
+}
diff --git a/app/Http/Controllers/Admin/LdapController.php b/app/Http/Controllers/Admin/LdapController.php
new file mode 100644
index 0000000..958731c
--- /dev/null
+++ b/app/Http/Controllers/Admin/LdapController.php
@@ -0,0 +1,83 @@
+ LdapConfig::first(),
+ ]);
+ }
+
+ public function update(Request $request)
+ {
+ $validated = $request->validate([
+ 'host' => 'required|string',
+ 'port' => 'required|integer',
+ 'use_ssl' => 'boolean',
+ 'use_tls' => 'boolean',
+ 'base_dn' => 'required|string',
+ 'bind_dn' => 'nullable|string',
+ 'bind_password' => 'nullable|string',
+ 'user_search_base' => 'nullable|string',
+ 'user_filter' => 'required|string',
+ 'admin_group_dn' => 'nullable|string',
+ 'is_active' => 'boolean',
+ ]);
+
+ $config = LdapConfig::firstOrNew([]);
+ $config->fill($validated);
+
+ if (empty($validated['bind_password'])) {
+ unset($config->bind_password);
+ }
+
+ $config->save();
+ activity()->causedBy(auth()->user())->log('LDAP configuration updated');
+
+ return back()->with('success', 'LDAP configuration saved.');
+ }
+
+ public function test(Request $request)
+ {
+ try {
+ $config = $request->validate([
+ 'host' => 'required', 'port' => 'required',
+ 'bind_dn' => 'nullable', 'bind_password' => 'nullable',
+ 'base_dn' => 'required',
+ ]);
+
+ $conn = ldap_connect("ldap://{$config['host']}:{$config['port']}");
+ ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
+ ldap_set_option($conn, LDAP_OPT_REFERRALS, 0);
+ ldap_set_option($conn, LDAP_OPT_TIMELIMIT, 5);
+
+ $bound = $config['bind_dn']
+ ? @ldap_bind($conn, $config['bind_dn'], $config['bind_password'])
+ : @ldap_bind($conn);
+
+ ldap_close($conn);
+
+ if (!$bound) {
+ return response()->json(['success' => false, 'message' => 'Bind failed. Check credentials.']);
+ }
+
+ return response()->json(['success' => true, 'message' => 'LDAP connection and bind successful!']);
+ } catch (\Throwable $e) {
+ return response()->json(['success' => false, 'message' => $e->getMessage()]);
+ }
+ }
+
+ public function sync()
+ {
+ // Sync LDAP users to local database
+ return back()->with('success', 'LDAP sync started.');
+ }
+}
diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php
new file mode 100644
index 0000000..53ceac2
--- /dev/null
+++ b/app/Http/Controllers/Admin/SettingsController.php
@@ -0,0 +1,27 @@
+mapWithKeys(fn($s) => [$s->key => $s->value]);
+ return Inertia::render('Admin/Settings', ['settings' => $settings]);
+ }
+
+ public function update(Request $request)
+ {
+ foreach ($request->except('_token') as $key => $value) {
+ Setting::set($key, $value);
+ }
+
+ activity()->causedBy(auth()->user())->log('Settings updated');
+ return back()->with('success', 'Settings saved.');
+ }
+}
diff --git a/app/Http/Controllers/Api/MetricsController.php b/app/Http/Controllers/Api/MetricsController.php
new file mode 100644
index 0000000..dae1d03
--- /dev/null
+++ b/app/Http/Controllers/Api/MetricsController.php
@@ -0,0 +1,21 @@
+json([
+ 'cpu' => rand(10, 90),
+ 'memory' => rand(20, 85),
+ 'disk' => rand(5, 70),
+ 'net_rx' => rand(0, 500),
+ 'net_tx' => rand(0, 200),
+ 'ts' => now()->toIso8601String(),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/WebSocketController.php b/app/Http/Controllers/Api/WebSocketController.php
new file mode 100644
index 0000000..9cc9d3d
--- /dev/null
+++ b/app/Http/Controllers/Api/WebSocketController.php
@@ -0,0 +1,15 @@
+json(['ticket' => base64_encode(random_bytes(32))]);
+ }
+}
diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php
new file mode 100644
index 0000000..47b5c0d
--- /dev/null
+++ b/app/Http/Controllers/Auth/AuthController.php
@@ -0,0 +1,83 @@
+ config('ldap.auth.enabled', false),
+ ]);
+ }
+
+ public function login(Request $request)
+ {
+ $request->validate([
+ 'email' => 'required|string',
+ 'password' => 'required|string',
+ ]);
+
+ // Rate limiting
+ $key = 'login:' . $request->ip();
+ if (RateLimiter::tooManyAttempts($key, 5)) {
+ $seconds = RateLimiter::availableIn($key);
+ return back()->withErrors([
+ 'email' => "Too many login attempts. Please try again in {$seconds} seconds.",
+ ]);
+ }
+
+ $credentials = [
+ 'email' => $request->email,
+ 'password' => $request->password,
+ ];
+
+ if (!Auth::attempt($credentials, $request->boolean('remember'))) {
+ RateLimiter::hit($key, 300);
+ return back()->withErrors(['email' => 'Invalid credentials.']);
+ }
+
+ RateLimiter::clear($key);
+
+ $user = Auth::user();
+ if (!$user->is_active) {
+ Auth::logout();
+ return back()->withErrors(['email' => 'Your account has been disabled.']);
+ }
+
+ $user->update([
+ 'last_login_at' => now(),
+ 'last_login_ip' => $request->ip(),
+ ]);
+
+ activity()
+ ->causedBy($user)
+ ->log('User logged in from ' . $request->ip());
+
+ $request->session()->regenerate();
+
+ return redirect()->intended('/');
+ }
+
+ public function logout(Request $request)
+ {
+ activity()
+ ->causedBy(Auth::user())
+ ->log('User logged out');
+
+ Auth::logout();
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ return redirect()->route('login');
+ }
+}
diff --git a/app/Http/Controllers/Backup/BackupController.php b/app/Http/Controllers/Backup/BackupController.php
new file mode 100644
index 0000000..6606d96
--- /dev/null
+++ b/app/Http/Controllers/Backup/BackupController.php
@@ -0,0 +1,52 @@
+ Backup::orderByDesc('created_at')->paginate(30),
+ 'policies' => BackupPolicy::all(),
+ 'stats' => [
+ 'total' => Backup::count(),
+ 'success' => Backup::where('status', 'success')->count(),
+ 'failed' => Backup::where('status', 'failed')->count(),
+ 'total_size' => '—',
+ ],
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ return back()->with('success', 'Backup queued.');
+ }
+
+ public function show(Backup $backup)
+ {
+ return Inertia::render('Backup/Show', ['backup' => $backup]);
+ }
+
+ public function destroy(Backup $backup)
+ {
+ $backup->delete();
+ return back()->with('success', 'Backup deleted.');
+ }
+
+ public function schedules()
+ {
+ return Inertia::render('Backup/Schedules', ['policies' => BackupPolicy::all()]);
+ }
+
+ public function storeSchedule(Request $request)
+ {
+ return back()->with('success', 'Schedule created.');
+ }
+}
diff --git a/app/Http/Controllers/Backup/RestoreController.php b/app/Http/Controllers/Backup/RestoreController.php
new file mode 100644
index 0000000..b69e5d4
--- /dev/null
+++ b/app/Http/Controllers/Backup/RestoreController.php
@@ -0,0 +1,19 @@
+validate([
+ 'restore_mode' => 'required|in:overwrite,new,test',
+ ]);
+ // Dispatch restore job
+ return back()->with('success', 'Restore initiated.');
+ }
+}
diff --git a/app/Http/Controllers/Cloud/CloudAccountController.php b/app/Http/Controllers/Cloud/CloudAccountController.php
new file mode 100644
index 0000000..43be618
--- /dev/null
+++ b/app/Http/Controllers/Cloud/CloudAccountController.php
@@ -0,0 +1,69 @@
+ CloudAccount::orderBy('name')->get(),
+ 'resources' => CloudResource::with('account')->orderByDesc('created_at')->paginate(30),
+ 'stats' => [],
+ ]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Cloud/AccountCreate');
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string|max:100',
+ 'provider' => 'required|in:aws,alibaba,tencent,gcp,azure,digitalocean,vultr,hetzner',
+ 'region' => 'nullable|string',
+ 'credentials' => 'required|array',
+ ]);
+ CloudAccount::create([...$validated, 'created_by' => auth()->id()]);
+ return redirect()->route('cloud.index')->with('success', 'Cloud account added.');
+ }
+
+ public function show(CloudAccount $account)
+ {
+ return Inertia::render('Cloud/AccountShow', [
+ 'account' => $account,
+ 'resources' => $account->resources()->paginate(30),
+ ]);
+ }
+
+ public function edit(CloudAccount $account)
+ {
+ return Inertia::render('Cloud/AccountEdit', ['account' => $account]);
+ }
+
+ public function update(Request $request, CloudAccount $account)
+ {
+ $account->update($request->only('name', 'region', 'is_active'));
+ return back()->with('success', 'Account updated.');
+ }
+
+ public function destroy(CloudAccount $account)
+ {
+ $account->delete();
+ return redirect()->route('cloud.index')->with('success', 'Account removed.');
+ }
+
+ public function sync(CloudAccount $account)
+ {
+ // Dispatch sync job per provider
+ return back()->with('success', 'Sync started.');
+ }
+}
diff --git a/app/Http/Controllers/Cloud/CloudResourceController.php b/app/Http/Controllers/Cloud/CloudResourceController.php
new file mode 100644
index 0000000..557ec27
--- /dev/null
+++ b/app/Http/Controllers/Cloud/CloudResourceController.php
@@ -0,0 +1,22 @@
+ CloudResource::with('account')->paginate(50),
+ ]);
+ }
+
+ public function show(CloudResource $resource)
+ {
+ return Inertia::render('Cloud/ResourceShow', ['resource' => $resource->load('account')]);
+ }
+}
diff --git a/app/Http/Controllers/Container/ContainerController.php b/app/Http/Controllers/Container/ContainerController.php
new file mode 100644
index 0000000..62961f9
--- /dev/null
+++ b/app/Http/Controllers/Container/ContainerController.php
@@ -0,0 +1,54 @@
+ Container::with('endpoint')->orderBy('name')->paginate(50),
+ 'endpoints' => DockerEndpoint::orderBy('name')->get(),
+ ]);
+ }
+
+ public function show(Container $container)
+ {
+ return Inertia::render('Container/Show', ['container' => $container->load('endpoint')]);
+ }
+
+ public function action(Request $request, Container $container)
+ {
+ $action = $request->validate(['action' => 'required|string'])['action'];
+ $this->docker->action($container->endpoint, $container->container_id, $action);
+ return back()->with('success', "Container {$action} executed.");
+ }
+
+ public function logs(Container $container)
+ {
+ $logs = $this->docker->logs($container->endpoint, $container->container_id);
+ return Inertia::render('Container/Logs', ['container' => $container, 'logs' => $logs]);
+ }
+
+ public function stats(Container $container)
+ {
+ $stats = $this->docker->stats($container->endpoint, $container->container_id);
+ return response()->json($stats);
+ }
+
+ public function destroy(Container $container)
+ {
+ $this->docker->removeContainer($container->endpoint, $container->container_id, true);
+ $container->delete();
+ return redirect()->route('containers.index')->with('success', 'Container removed.');
+ }
+}
diff --git a/app/Http/Controllers/Container/StackController.php b/app/Http/Controllers/Container/StackController.php
new file mode 100644
index 0000000..9f493de
--- /dev/null
+++ b/app/Http/Controllers/Container/StackController.php
@@ -0,0 +1,68 @@
+ Stack::with('endpoint')->orderBy('name')->paginate(20),
+ 'endpoints' => DockerEndpoint::all(),
+ ]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Container/StackCreate', ['endpoints' => DockerEndpoint::all()]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string|max:100',
+ 'endpoint_id' => 'required|exists:docker_endpoints,id',
+ 'compose_content' => 'required|string',
+ 'env_vars' => 'nullable|array',
+ ]);
+ Stack::create([...$validated, 'created_by' => auth()->id()]);
+ return redirect()->route('stacks.index')->with('success', 'Stack created.');
+ }
+
+ public function show(Stack $stack)
+ {
+ return Inertia::render('Container/StackShow', ['stack' => $stack->load('endpoint')]);
+ }
+
+ public function edit(Stack $stack)
+ {
+ return Inertia::render('Container/StackEdit', ['stack' => $stack, 'endpoints' => DockerEndpoint::all()]);
+ }
+
+ public function update(Request $request, Stack $stack)
+ {
+ $stack->update($request->only('name', 'compose_content', 'env_vars'));
+ return redirect()->route('stacks.show', $stack)->with('success', 'Stack updated.');
+ }
+
+ public function destroy(Stack $stack)
+ {
+ $stack->delete();
+ return redirect()->route('stacks.index')->with('success', 'Stack deleted.');
+ }
+
+ public function deploy(Stack $stack)
+ {
+ $this->docker->deployStack($stack->endpoint, $stack->name, $stack->compose_content, $stack->env_vars ?? []);
+ return back()->with('success', 'Stack deployed.');
+ }
+}
diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php
new file mode 100644
index 0000000..e23d60b
--- /dev/null
+++ b/app/Http/Controllers/DashboardController.php
@@ -0,0 +1,68 @@
+get();
+ $vmStats = VirtualMachine::selectRaw('
+ COUNT(*) as total,
+ SUM(CASE WHEN status = "running" THEN 1 ELSE 0 END) as running,
+ SUM(CASE WHEN status = "stopped" THEN 1 ELSE 0 END) as stopped,
+ AVG(cpu_usage) as avg_cpu,
+ AVG(memory_usage) as avg_memory
+ ')->first();
+
+ $containerStats = Container::selectRaw('
+ COUNT(*) as total,
+ SUM(CASE WHEN state = "running" THEN 1 ELSE 0 END) as running,
+ SUM(CASE WHEN state = "exited" THEN 1 ELSE 0 END) as stopped
+ ')->first();
+
+ $endpoints = DockerEndpoint::select('id', 'name', 'type', 'status')->get();
+ $cloudAccounts = CloudAccount::select('id', 'name', 'provider', 'status')->get();
+ $criticalAlerts = Alert::where('status', 'firing')->where('severity', 'critical')->count();
+ $warningAlerts = Alert::where('status', 'firing')->where('severity', 'warning')->count();
+ $recentAlerts = Alert::with('rule')
+ ->where('status', 'firing')
+ ->orderByDesc('created_at')
+ ->limit(5)
+ ->get();
+
+ $avgSecurityScore = SecurityScore::avg('score') ?? 0;
+
+ return Inertia::render('Dashboard/Index', [
+ 'stats' => [
+ 'hypervisors' => $hypervisors->count(),
+ 'hypervisors_online' => $hypervisors->where('status', 'online')->count(),
+ 'vms' => (int) ($vmStats->total ?? 0),
+ 'vms_running' => (int) ($vmStats->running ?? 0),
+ 'vms_stopped' => (int) ($vmStats->stopped ?? 0),
+ 'avg_cpu' => round((float) ($vmStats->avg_cpu ?? 0), 1),
+ 'avg_memory' => round((float) ($vmStats->avg_memory ?? 0), 1),
+ 'containers' => (int) ($containerStats->total ?? 0),
+ 'containers_running' => (int) ($containerStats->running ?? 0),
+ 'cloud_accounts' => $cloudAccounts->count(),
+ 'critical_alerts' => $criticalAlerts,
+ 'warning_alerts' => $warningAlerts,
+ 'security_score' => round($avgSecurityScore, 1),
+ ],
+ 'hypervisors' => $hypervisors,
+ 'endpoints' => $endpoints,
+ 'cloud_accounts' => $cloudAccounts,
+ 'recent_alerts' => $recentAlerts,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Hypervisor/HypervisorController.php b/app/Http/Controllers/Hypervisor/HypervisorController.php
new file mode 100644
index 0000000..3cf99d7
--- /dev/null
+++ b/app/Http/Controllers/Hypervisor/HypervisorController.php
@@ -0,0 +1,121 @@
+orderByDesc('created_at')
+ ->paginate(20);
+
+ return Inertia::render('Hypervisor/Index', ['hypervisors' => $hypervisors]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Hypervisor/Create');
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string|max:100',
+ 'type' => 'required|in:vcenter,esxi,proxmox,hyper-v,xen,kvm',
+ 'host' => 'required|string',
+ 'port' => 'required|integer|between:1,65535',
+ 'username' => 'required|string',
+ 'password' => 'required|string',
+ 'ssl_verify' => 'boolean',
+ 'datacenter' => 'nullable|string',
+ ]);
+
+ $hypervisor = Hypervisor::create([
+ ...$validated,
+ 'created_by' => auth()->id(),
+ 'status' => 'unknown',
+ ]);
+
+ activity()->causedBy(auth()->user())->performedOn($hypervisor)->log('Hypervisor created');
+
+ return redirect()->route('hypervisors.show', $hypervisor)
+ ->with('success', 'Hypervisor added successfully.');
+ }
+
+ public function show(Hypervisor $hypervisor)
+ {
+ $hypervisor->load('virtualMachines');
+ $vmStats = $hypervisor->virtualMachines()
+ ->selectRaw('status, COUNT(*) as count')
+ ->groupBy('status')
+ ->pluck('count', 'status');
+
+ return Inertia::render('Hypervisor/Show', [
+ 'hypervisor' => $hypervisor,
+ 'vm_stats' => $vmStats,
+ 'vms' => $hypervisor->virtualMachines()->orderBy('name')->paginate(50),
+ ]);
+ }
+
+ public function edit(Hypervisor $hypervisor)
+ {
+ return Inertia::render('Hypervisor/Edit', ['hypervisor' => $hypervisor->makeVisible('password')]);
+ }
+
+ public function update(Request $request, Hypervisor $hypervisor)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string|max:100',
+ 'host' => 'required|string',
+ 'port' => 'required|integer|between:1,65535',
+ 'username' => 'required|string',
+ 'password' => 'nullable|string',
+ 'ssl_verify' => 'boolean',
+ 'datacenter' => 'nullable|string',
+ ]);
+
+ if (empty($validated['password'])) {
+ unset($validated['password']);
+ }
+
+ $hypervisor->update($validated);
+ activity()->causedBy(auth()->user())->performedOn($hypervisor)->log('Hypervisor updated');
+
+ return redirect()->route('hypervisors.show', $hypervisor)
+ ->with('success', 'Hypervisor updated.');
+ }
+
+ public function destroy(Hypervisor $hypervisor)
+ {
+ $hypervisor->delete();
+ activity()->causedBy(auth()->user())->log("Hypervisor '{$hypervisor->name}' deleted");
+
+ return redirect()->route('hypervisors.index')->with('success', 'Hypervisor removed.');
+ }
+
+ public function sync(Hypervisor $hypervisor)
+ {
+ try {
+ $this->manager->driver($hypervisor)->sync();
+ return back()->with('success', 'Sync completed successfully.');
+ } catch (\Throwable $e) {
+ $hypervisor->update(['status' => 'error', 'last_error' => $e->getMessage()]);
+ return back()->with('error', 'Sync failed: ' . $e->getMessage());
+ }
+ }
+
+ public function testConnection(Request $request, Hypervisor $hypervisor)
+ {
+ $result = $this->manager->testConnection($hypervisor);
+ return response()->json($result);
+ }
+}
diff --git a/app/Http/Controllers/Hypervisor/TemplateController.php b/app/Http/Controllers/Hypervisor/TemplateController.php
new file mode 100644
index 0000000..0ab02af
--- /dev/null
+++ b/app/Http/Controllers/Hypervisor/TemplateController.php
@@ -0,0 +1,77 @@
+ VmTemplate::with('hypervisor')->orderBy('name')->paginate(20),
+ ]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Hypervisor/TemplateCreate', [
+ 'hypervisors' => Hypervisor::where('status', 'online')->get(),
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string|max:100',
+ 'hypervisor_id' => 'nullable|exists:hypervisors,id',
+ 'os_type' => 'required|string',
+ 'cpu_count' => 'integer|min:1',
+ 'memory_mb' => 'integer|min:512',
+ 'disk_gb' => 'integer|min:1',
+ 'customization' => 'nullable|array',
+ 'provision_script' => 'nullable|string',
+ ]);
+ VmTemplate::create([...$validated, 'created_by' => auth()->id()]);
+ return redirect()->route('templates.index')->with('success', 'Template created.');
+ }
+
+ public function show(VmTemplate $template)
+ {
+ return Inertia::render('Hypervisor/TemplateShow', ['template' => $template->load('hypervisor')]);
+ }
+
+ public function edit(VmTemplate $template)
+ {
+ return Inertia::render('Hypervisor/TemplateEdit', [
+ 'template' => $template,
+ 'hypervisors' => Hypervisor::where('status', 'online')->get(),
+ ]);
+ }
+
+ public function update(Request $request, VmTemplate $template)
+ {
+ $template->update($request->except('_token'));
+ return redirect()->route('templates.show', $template)->with('success', 'Template updated.');
+ }
+
+ public function destroy(VmTemplate $template)
+ {
+ $template->delete();
+ return redirect()->route('templates.index')->with('success', 'Template deleted.');
+ }
+
+ public function deploy(Request $request, VmTemplate $template)
+ {
+ $data = $request->validate([
+ 'vm_name' => 'required|string',
+ 'hypervisor_id' => 'required|exists:hypervisors,id',
+ ]);
+ // Dispatch deploy job
+ return back()->with('success', 'Deployment queued.');
+ }
+}
diff --git a/app/Http/Controllers/Hypervisor/VirtualMachineController.php b/app/Http/Controllers/Hypervisor/VirtualMachineController.php
new file mode 100644
index 0000000..8a80638
--- /dev/null
+++ b/app/Http/Controllers/Hypervisor/VirtualMachineController.php
@@ -0,0 +1,60 @@
+ VirtualMachine::with('hypervisor')->orderBy('name')->paginate(50),
+ ]);
+ }
+
+ public function show(VirtualMachine $vm)
+ {
+ return Inertia::render('Hypervisor/VMShow', ['vm' => $vm->load('hypervisor')]);
+ }
+
+ public function power(Request $request, VirtualMachine $vm)
+ {
+ $action = $request->validate(['action' => 'required|in:start,stop,reboot,suspend'])['action'];
+ $driver = $this->manager->driver($vm->hypervisor);
+
+ match ($action) {
+ 'start' => $driver->powerOn($vm->remote_id),
+ 'stop' => $driver->powerOff($vm->remote_id),
+ 'reboot' => $driver->reboot($vm->remote_id),
+ 'suspend' => $driver->suspend($vm->remote_id),
+ };
+
+ return back()->with('success', "VM {$action} initiated.");
+ }
+
+ public function snapshot(Request $request, VirtualMachine $vm)
+ {
+ $data = $request->validate(['name' => 'required|string', 'description' => 'nullable|string']);
+ $this->manager->driver($vm->hypervisor)->createSnapshot($vm->remote_id, $data['name'], $data['description'] ?? '');
+ return back()->with('success', 'Snapshot created.');
+ }
+
+ public function clone(Request $request, VirtualMachine $vm)
+ {
+ $data = $request->validate(['name' => 'required|string']);
+ $this->manager->driver($vm->hypervisor)->cloneVM($vm->remote_id, $data['name'], []);
+ return back()->with('success', 'Clone initiated.');
+ }
+
+ public function migrate(Request $request, VirtualMachine $vm)
+ {
+ return back()->with('info', 'Migration not yet implemented for this hypervisor type.');
+ }
+}
diff --git a/app/Http/Controllers/Identity/CertificateController.php b/app/Http/Controllers/Identity/CertificateController.php
new file mode 100644
index 0000000..ccc8031
--- /dev/null
+++ b/app/Http/Controllers/Identity/CertificateController.php
@@ -0,0 +1,69 @@
+ Certificate::orderBy('expires_at')->paginate(20),
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'name' => 'required|string',
+ 'domain' => 'required|string',
+ 'certificate' => 'required|string',
+ 'private_key' => 'required|string',
+ 'type' => 'required|in:self-signed,lets-encrypt,custom',
+ 'expires_at' => 'required|date',
+ ]);
+ Certificate::create([...$validated, 'status' => 'valid', 'created_by' => auth()->id()]);
+ return back()->with('success', 'Certificate imported.');
+ }
+
+ public function show(Certificate $cert)
+ {
+ return Inertia::render('Identity/CertShow', ['cert' => $cert]);
+ }
+
+ public function destroy(Certificate $cert)
+ {
+ $cert->delete();
+ return back()->with('success', 'Certificate deleted.');
+ }
+
+ public function generate(Request $request)
+ {
+ $data = $request->validate(['domain' => 'required|string', 'days' => 'integer|min:1|max:3650']);
+ $days = $data['days'] ?? 365;
+
+ $privateKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
+ $csr = openssl_csr_new(['CN' => $data['domain']], $privateKey);
+ $cert = openssl_csr_sign($csr, null, $privateKey, $days);
+
+ openssl_x509_export($cert, $certOut);
+ openssl_pkey_export($privateKey, $keyOut);
+
+ Certificate::create([
+ 'name' => $data['domain'],
+ 'domain' => $data['domain'],
+ 'certificate' => $certOut,
+ 'private_key' => $keyOut,
+ 'type' => 'self-signed',
+ 'expires_at' => now()->addDays($days),
+ 'status' => 'valid',
+ 'created_by' => auth()->id(),
+ ]);
+
+ return back()->with('success', 'Self-signed certificate generated.');
+ }
+}
diff --git a/app/Http/Controllers/Identity/PasswordController.php b/app/Http/Controllers/Identity/PasswordController.php
new file mode 100644
index 0000000..ea977b9
--- /dev/null
+++ b/app/Http/Controllers/Identity/PasswordController.php
@@ -0,0 +1,60 @@
+ auth()->user()->passwordEntries()->orderBy('title')->get(),
+ 'certificates' => [],
+ 'ssh_keys' => [],
+ 'stats' => [
+ 'passwords' => auth()->user()->passwordEntries()->count(),
+ 'certs' => 0,
+ 'expiring' => 0,
+ 'ssh_keys' => 0,
+ ],
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'title' => 'required|string',
+ 'username' => 'nullable|string',
+ 'password' => 'required|string',
+ 'url' => 'nullable|url',
+ 'notes' => 'nullable|string',
+ 'category' => 'nullable|string',
+ ]);
+ auth()->user()->passwordEntries()->create($validated);
+ return back()->with('success', 'Password entry created.');
+ }
+
+ public function reveal(PasswordEntry $password)
+ {
+ $this->authorize('view', $password);
+ return response()->json(['password' => $password->getDecryptedPassword()]);
+ }
+
+ public function update(Request $request, PasswordEntry $password)
+ {
+ $this->authorize('update', $password);
+ $password->update($request->except('_token'));
+ return back()->with('success', 'Entry updated.');
+ }
+
+ public function destroy(PasswordEntry $password)
+ {
+ $this->authorize('delete', $password);
+ $password->delete();
+ return back()->with('success', 'Entry deleted.');
+ }
+}
diff --git a/app/Http/Controllers/Monitoring/MonitoringController.php b/app/Http/Controllers/Monitoring/MonitoringController.php
new file mode 100644
index 0000000..79d9eaf
--- /dev/null
+++ b/app/Http/Controllers/Monitoring/MonitoringController.php
@@ -0,0 +1,58 @@
+ Alert::with('rule')->orderByDesc('created_at')->limit(50)->get(),
+ 'alert_stats' => [
+ 'total_rules' => AlertRule::count(),
+ 'resolved_24h' => Alert::where('status', 'resolved')->where('resolved_at', '>=', now()->subDay())->count(),
+ ],
+ 'dashboards' => [],
+ ]);
+ }
+
+ public function metrics(string $target)
+ {
+ [$type, $id] = explode(':', $target, 2);
+ $metrics = Metric::where('target_type', $type)
+ ->where('target_id', $id)
+ ->where('recorded_at', '>=', now()->subHour())
+ ->orderBy('recorded_at')
+ ->get();
+
+ return response()->json($metrics);
+ }
+
+ public function alerts()
+ {
+ return Inertia::render('Monitoring/Alerts', [
+ 'rules' => AlertRule::orderByDesc('created_at')->paginate(20),
+ ]);
+ }
+
+ public function acknowledge(Alert $alert)
+ {
+ $alert->update([
+ 'status' => 'acknowledged',
+ 'acknowledged_by' => auth()->id(),
+ 'acknowledged_at' => now(),
+ ]);
+ return back()->with('success', 'Alert acknowledged.');
+ }
+
+ public function dashboards()
+ {
+ return Inertia::render('Monitoring/Dashboards');
+ }
+}
diff --git a/app/Http/Controllers/Security/ScanController.php b/app/Http/Controllers/Security/ScanController.php
new file mode 100644
index 0000000..78fa70e
--- /dev/null
+++ b/app/Http/Controllers/Security/ScanController.php
@@ -0,0 +1,44 @@
+ SecurityScan::orderByDesc('created_at')->paginate(20),
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'target_type' => 'required|string',
+ 'target_id' => 'required|integer',
+ 'scan_type' => 'required|string',
+ ]);
+
+ SecurityScan::create([
+ ...$validated,
+ 'target_name' => 'Scan Target',
+ 'status' => 'queued',
+ 'initiated_by' => auth()->id(),
+ ]);
+
+ // Dispatch scan job
+ // \App\Jobs\RunSecurityScan::dispatch($scan);
+
+ return back()->with('success', 'Scan queued.');
+ }
+
+ public function show(SecurityScan $scan)
+ {
+ return Inertia::render('Security/ScanShow', ['scan' => $scan]);
+ }
+}
diff --git a/app/Http/Controllers/Security/SecurityDashboardController.php b/app/Http/Controllers/Security/SecurityDashboardController.php
new file mode 100644
index 0000000..5dc995a
--- /dev/null
+++ b/app/Http/Controllers/Security/SecurityDashboardController.php
@@ -0,0 +1,39 @@
+paginate(15);
+ $scores = SecurityScore::selectRaw('AVG(score) as avg, MIN(score) as min, MAX(score) as max')->first();
+ $stats = SecurityScan::whereIn('status', ['completed'])->selectRaw('
+ SUM(critical_count) as critical,
+ SUM(high_count) as high,
+ SUM(medium_count) as medium,
+ SUM(low_count) as low
+ ')->first();
+
+ return Inertia::render('Security/Index', [
+ 'scans' => $scans,
+ 'scores' => $scores,
+ 'stats' => $stats,
+ ]);
+ }
+
+ public function vulnerabilities()
+ {
+ return Inertia::render('Security/Vulnerabilities');
+ }
+
+ public function compliance()
+ {
+ return Inertia::render('Security/Compliance');
+ }
+}
diff --git a/app/Http/Controllers/Wiki/DocumentController.php b/app/Http/Controllers/Wiki/DocumentController.php
new file mode 100644
index 0000000..dd5f539
--- /dev/null
+++ b/app/Http/Controllers/Wiki/DocumentController.php
@@ -0,0 +1,96 @@
+markdown = new CommonMarkConverter([
+ 'html_input' => 'strip',
+ 'allow_unsafe_links' => false,
+ ]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Wiki/PageCreate', [
+ 'spaces' => WikiSpace::all(),
+ ]);
+ }
+
+ public function store(Request $request)
+ {
+ $validated = $request->validate([
+ 'space_id' => 'required|exists:wiki_spaces,id',
+ 'title' => 'required|string|max:255',
+ 'content' => 'required|string',
+ 'parent_id' => 'nullable|exists:wiki_pages,id',
+ 'tags' => 'nullable|array',
+ ]);
+
+ $slug = \Str::slug($validated['title']);
+ $page = WikiPage::create([
+ ...$validated,
+ 'slug' => $slug,
+ 'content_html' => $this->markdown->convert($validated['content'])->getContent(),
+ 'created_by' => auth()->id(),
+ 'updated_by' => auth()->id(),
+ ]);
+
+ return redirect()->route('wiki.pages.show', $page)->with('success', 'Page created.');
+ }
+
+ public function show(WikiPage $page)
+ {
+ return Inertia::render('Wiki/PageShow', [
+ 'page' => $page->load('space', 'createdBy', 'updatedBy'),
+ 'children' => $page->children()->get(),
+ ]);
+ }
+
+ public function edit(WikiPage $page)
+ {
+ return Inertia::render('Wiki/PageEdit', [
+ 'page' => $page,
+ 'spaces' => WikiSpace::all(),
+ ]);
+ }
+
+ public function update(Request $request, WikiPage $page)
+ {
+ $validated = $request->validate([
+ 'title' => 'required|string|max:255',
+ 'content' => 'required|string',
+ ]);
+
+ // Save revision
+ $page->revisions()->create([
+ 'content' => $page->content,
+ 'created_by' => auth()->id(),
+ ]);
+
+ $page->update([
+ ...$validated,
+ 'content_html' => $this->markdown->convert($validated['content'])->getContent(),
+ 'updated_by' => auth()->id(),
+ ]);
+
+ return redirect()->route('wiki.pages.show', $page)->with('success', 'Page updated.');
+ }
+
+ public function destroy(WikiPage $page)
+ {
+ $page->delete();
+ return redirect()->route('wiki.index')->with('success', 'Page deleted.');
+ }
+}
diff --git a/app/Http/Controllers/Wiki/WikiController.php b/app/Http/Controllers/Wiki/WikiController.php
new file mode 100644
index 0000000..8206566
--- /dev/null
+++ b/app/Http/Controllers/Wiki/WikiController.php
@@ -0,0 +1,54 @@
+ WikiSpace::withCount('pages')->get(),
+ 'recent_pages' => WikiPage::with('space')->orderByDesc('updated_at')->limit(20)->get(),
+ 'files' => [],
+ ]);
+ }
+
+ public function spaces()
+ {
+ return Inertia::render('Wiki/Spaces', [
+ 'spaces' => WikiSpace::withCount('pages')->get(),
+ ]);
+ }
+
+ public function space(WikiSpace $space)
+ {
+ return Inertia::render('Wiki/Space', [
+ 'space' => $space->load('pages'),
+ 'pages' => $space->pages()->orderBy('order')->get(),
+ ]);
+ }
+
+ public function files()
+ {
+ return Inertia::render('Wiki/Files');
+ }
+
+ public function uploadFile(Request $request)
+ {
+ $request->validate(['file' => 'required|file|max:102400']);
+ $path = $request->file('file')->store('wiki-files');
+ return back()->with('success', 'File uploaded.');
+ }
+
+ public function deleteFile(string $file)
+ {
+ \Storage::delete('wiki-files/' . $file);
+ return back()->with('success', 'File deleted.');
+ }
+}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
new file mode 100644
index 0000000..a372181
--- /dev/null
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -0,0 +1,74 @@
+user();
+
+ return array_merge(parent::share($request), [
+ 'auth' => [
+ 'user' => $user ? [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'avatar' => $user->avatar_url,
+ 'roles' => $user->getRoleNames(),
+ 'permissions' => $user->getAllPermissions()->pluck('name'),
+ ] : null,
+ ],
+ 'flash' => [
+ 'success' => fn() => $request->session()->get('success'),
+ 'error' => fn() => $request->session()->get('error'),
+ 'warning' => fn() => $request->session()->get('warning'),
+ 'info' => fn() => $request->session()->get('info'),
+ ],
+ 'app' => [
+ 'name' => config('app.name'),
+ 'version' => config('app.version', '1.0.0'),
+ 'env' => config('app.env'),
+ ],
+ 'navigation' => $this->buildNavigation($user),
+ ]);
+ }
+
+ private function buildNavigation($user): array
+ {
+ if (!$user) return [];
+
+ $nav = [
+ ['name' => 'Dashboard', 'href' => '/', 'icon' => 'LayoutDashboard', 'key' => 'dashboard'],
+ ['name' => 'Hypervisors', 'href' => '/hypervisors', 'icon' => 'Server', 'key' => 'hypervisors'],
+ ['name' => 'VMs', 'href' => '/virtual-machines', 'icon' => 'Monitor', 'key' => 'vms'],
+ ['name' => 'Templates', 'href' => '/templates', 'icon' => 'Layers', 'key' => 'templates'],
+ ['name' => 'Containers', 'href' => '/containers', 'icon' => 'Box', 'key' => 'containers'],
+ ['name' => 'Stacks', 'href' => '/stacks', 'icon' => 'Boxes', 'key' => 'stacks'],
+ ['name' => 'Cloud', 'href' => '/cloud', 'icon' => 'Cloud', 'key' => 'cloud'],
+ ['name' => 'Security', 'href' => '/security', 'icon' => 'Shield', 'key' => 'security'],
+ ['name' => 'Backups', 'href' => '/backups', 'icon' => 'HardDrive', 'key' => 'backups'],
+ ['name' => 'Monitoring', 'href' => '/monitoring', 'icon' => 'Activity', 'key' => 'monitoring'],
+ ['name' => 'Identity', 'href' => '/identity', 'icon' => 'KeyRound', 'key' => 'identity'],
+ ['name' => 'Wiki', 'href' => '/wiki', 'icon' => 'BookOpen', 'key' => 'wiki'],
+ ];
+
+ if ($user->hasRole(['admin', 'super-admin'])) {
+ $nav[] = ['name' => 'ACL', 'href' => '/acl', 'icon' => 'Users', 'key' => 'acl'];
+ $nav[] = ['name' => 'Admin', 'href' => '/admin', 'icon' => 'Settings','key' => 'admin'];
+ }
+
+ return $nav;
+ }
+}
diff --git a/app/Models/Alert.php b/app/Models/Alert.php
new file mode 100644
index 0000000..b6a6ec8
--- /dev/null
+++ b/app/Models/Alert.php
@@ -0,0 +1,22 @@
+ 'datetime',
+ 'resolved_at' => 'datetime',
+ ];
+
+ public function rule() { return $this->belongsTo(AlertRule::class); }
+ public function acknowledgedBy() { return $this->belongsTo(User::class, 'acknowledged_by'); }
+}
diff --git a/app/Models/AlertRule.php b/app/Models/AlertRule.php
new file mode 100644
index 0000000..42f2c2d
--- /dev/null
+++ b/app/Models/AlertRule.php
@@ -0,0 +1,14 @@
+ 'array', 'is_active' => 'boolean'];
+}
diff --git a/app/Models/Backup.php b/app/Models/Backup.php
new file mode 100644
index 0000000..c00d3d6
--- /dev/null
+++ b/app/Models/Backup.php
@@ -0,0 +1,16 @@
+ 'array', 'expires_at' => 'datetime', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
+ public function policy() { return $this->belongsTo(BackupPolicy::class); }
+}
diff --git a/app/Models/BackupPolicy.php b/app/Models/BackupPolicy.php
new file mode 100644
index 0000000..9b913af
--- /dev/null
+++ b/app/Models/BackupPolicy.php
@@ -0,0 +1,15 @@
+ 'array', 'storage_config' => 'array', 'compression' => 'array', 'encryption' => 'array', 'is_active' => 'boolean'];
+ public function backups() { return $this->hasMany(Backup::class, 'policy_id'); }
+}
diff --git a/app/Models/Certificate.php b/app/Models/Certificate.php
new file mode 100644
index 0000000..fac8fb5
--- /dev/null
+++ b/app/Models/Certificate.php
@@ -0,0 +1,34 @@
+ 'array',
+ 'tags' => 'array',
+ 'auto_renew' => 'boolean',
+ 'issued_at' => 'datetime',
+ 'expires_at' => 'datetime',
+ ];
+
+ public function setPrivateKeyAttribute(string $value): void
+ {
+ $this->attributes['private_key'] = Crypt::encryptString($value);
+ }
+
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/CloudAccount.php b/app/Models/CloudAccount.php
new file mode 100644
index 0000000..fab1b18
--- /dev/null
+++ b/app/Models/CloudAccount.php
@@ -0,0 +1,40 @@
+ 'boolean',
+ 'regions' => 'array',
+ 'tags' => 'array',
+ 'last_sync_at' => 'datetime',
+ ];
+
+ public function setCredentialsAttribute(array $value): void
+ {
+ $this->attributes['credentials'] = Crypt::encryptString(json_encode($value));
+ }
+
+ public function getCredentialsAttribute(string $value): array
+ {
+ return json_decode(Crypt::decryptString($value), true) ?? [];
+ }
+
+ public function resources() { return $this->hasMany(CloudResource::class, 'account_id'); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/CloudResource.php b/app/Models/CloudResource.php
new file mode 100644
index 0000000..3cce851
--- /dev/null
+++ b/app/Models/CloudResource.php
@@ -0,0 +1,15 @@
+ 'array', 'tags' => 'array'];
+ public function account() { return $this->belongsTo(CloudAccount::class); }
+}
diff --git a/app/Models/Container.php b/app/Models/Container.php
new file mode 100644
index 0000000..22337f2
--- /dev/null
+++ b/app/Models/Container.php
@@ -0,0 +1,23 @@
+ 'array',
+ 'volumes' => 'array',
+ 'env' => 'array',
+ 'labels' => 'array',
+ 'networks' => 'array',
+ ];
+
+ public function endpoint() { return $this->belongsTo(DockerEndpoint::class); }
+}
diff --git a/app/Models/DockerEndpoint.php b/app/Models/DockerEndpoint.php
new file mode 100644
index 0000000..25b37ad
--- /dev/null
+++ b/app/Models/DockerEndpoint.php
@@ -0,0 +1,29 @@
+ 'boolean',
+ 'info' => 'array',
+ 'last_sync_at' => 'datetime',
+ ];
+
+ public function containers() { return $this->hasMany(Container::class, 'endpoint_id'); }
+ public function stacks() { return $this->hasMany(Stack::class, 'endpoint_id'); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/Hypervisor.php b/app/Models/Hypervisor.php
new file mode 100644
index 0000000..5e82a1a
--- /dev/null
+++ b/app/Models/Hypervisor.php
@@ -0,0 +1,70 @@
+ 'boolean',
+ 'metadata' => 'array',
+ 'last_sync_at' => 'datetime',
+ ];
+
+ protected $hidden = ['password'];
+
+ public function setPasswordAttribute(string $value): void
+ {
+ $this->attributes['password'] = Crypt::encryptString($value);
+ }
+
+ public function getPasswordAttribute(string $value): string
+ {
+ return Crypt::decryptString($value);
+ }
+
+ public function virtualMachines()
+ {
+ return $this->hasMany(VirtualMachine::class);
+ }
+
+ public function templates()
+ {
+ return $this->hasMany(VmTemplate::class);
+ }
+
+ public function createdBy()
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+
+ public function isOnline(): bool
+ {
+ return $this->status === 'online';
+ }
+
+ public function getTypeLabel(): string
+ {
+ return match($this->type) {
+ 'vcenter' => 'VMware vCenter',
+ 'esxi' => 'VMware ESXi',
+ 'proxmox' => 'Proxmox VE',
+ 'hyper-v' => 'Microsoft Hyper-V',
+ 'xen' => 'Citrix XenServer',
+ 'kvm' => 'KVM/QEMU',
+ default => ucfirst($this->type),
+ };
+ }
+}
diff --git a/app/Models/LdapConfig.php b/app/Models/LdapConfig.php
new file mode 100644
index 0000000..ae6e1f5
--- /dev/null
+++ b/app/Models/LdapConfig.php
@@ -0,0 +1,35 @@
+ 'boolean',
+ 'use_tls' => 'boolean',
+ 'is_active' => 'boolean',
+ 'attribute_map' => 'array',
+ ];
+
+ public function setBindPasswordAttribute(?string $value): void
+ {
+ $this->attributes['bind_password'] = $value ? Crypt::encryptString($value) : null;
+ }
+
+ public function getBindPasswordAttribute(?string $value): ?string
+ {
+ return $value ? Crypt::decryptString($value) : null;
+ }
+}
diff --git a/app/Models/PasswordEntry.php b/app/Models/PasswordEntry.php
new file mode 100644
index 0000000..786f901
--- /dev/null
+++ b/app/Models/PasswordEntry.php
@@ -0,0 +1,40 @@
+ 'array',
+ 'shared_with' => 'array',
+ 'is_shared' => 'boolean',
+ 'expires_at' => 'datetime',
+ 'last_used_at' => 'datetime',
+ ];
+
+ public function setPasswordAttribute(string $value): void
+ {
+ $this->attributes['password'] = Crypt::encryptString($value);
+ }
+
+ public function getDecryptedPassword(): string
+ {
+ return Crypt::decryptString($this->attributes['password']);
+ }
+
+ public function user() { return $this->belongsTo(User::class); }
+}
diff --git a/app/Models/SecurityScan.php b/app/Models/SecurityScan.php
new file mode 100644
index 0000000..146ff72
--- /dev/null
+++ b/app/Models/SecurityScan.php
@@ -0,0 +1,19 @@
+ 'array',
+ 'started_at' => 'datetime',
+ 'completed_at' => 'datetime',
+ ];
+}
diff --git a/app/Models/SecurityScore.php b/app/Models/SecurityScore.php
new file mode 100644
index 0000000..fb962ae
--- /dev/null
+++ b/app/Models/SecurityScore.php
@@ -0,0 +1,11 @@
+ 'array', 'calculated_at' => 'datetime'];
+}
diff --git a/app/Models/Setting.php b/app/Models/Setting.php
new file mode 100644
index 0000000..9e0d3c2
--- /dev/null
+++ b/app/Models/Setting.php
@@ -0,0 +1,57 @@
+ 'boolean',
+ ];
+
+ public function getValueAttribute($value)
+ {
+ if ($this->is_encrypted && $value) {
+ $value = Crypt::decryptString($value);
+ }
+
+ return match ($this->type) {
+ 'boolean' => (bool) $value,
+ 'integer' => (int) $value,
+ 'json' => json_decode($value, true),
+ default => $value,
+ };
+ }
+
+ public function setValueAttribute($value): void
+ {
+ if ($this->type === 'json') {
+ $value = json_encode($value);
+ }
+
+ if ($this->is_encrypted) {
+ $value = Crypt::encryptString((string) $value);
+ }
+
+ $this->attributes['value'] = $value;
+ }
+
+ public static function get(string $key, $default = null)
+ {
+ $setting = static::where('key', $key)->first();
+ return $setting ? $setting->value : $default;
+ }
+
+ public static function set(string $key, $value, string $group = 'general', string $type = 'string'): void
+ {
+ static::updateOrCreate(['key' => $key], [
+ 'group' => $group,
+ 'value' => $value,
+ 'type' => $type,
+ ]);
+ }
+}
diff --git a/app/Models/Stack.php b/app/Models/Stack.php
new file mode 100644
index 0000000..71214e0
--- /dev/null
+++ b/app/Models/Stack.php
@@ -0,0 +1,15 @@
+ 'array'];
+ public function endpoint() { return $this->belongsTo(DockerEndpoint::class); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
new file mode 100644
index 0000000..2b96fcb
--- /dev/null
+++ b/app/Models/User.php
@@ -0,0 +1,58 @@
+ 'datetime',
+ 'last_login_at' => 'datetime',
+ 'is_active' => 'boolean',
+ 'mfa_enabled' => 'boolean',
+ 'mfa_recovery_codes' => 'encrypted:array',
+ 'mfa_secret' => 'encrypted',
+ 'preferences' => 'array',
+ 'password' => 'hashed',
+ ];
+
+ public function getAvatarUrlAttribute(): string
+ {
+ if ($this->avatar) {
+ return asset('storage/' . $this->avatar);
+ }
+ $hash = md5(strtolower(trim($this->email)));
+ return "https://www.gravatar.com/avatar/{$hash}?d=identicon&s=128";
+ }
+
+ public function isLdapUser(): bool
+ {
+ return $this->auth_source === 'ldap';
+ }
+
+ public function passwordEntries()
+ {
+ return $this->hasMany(PasswordEntry::class);
+ }
+}
diff --git a/app/Models/VirtualMachine.php b/app/Models/VirtualMachine.php
new file mode 100644
index 0000000..045991e
--- /dev/null
+++ b/app/Models/VirtualMachine.php
@@ -0,0 +1,23 @@
+ 'array',
+ 'metadata' => 'array',
+ ];
+
+ public function hypervisor() { return $this->belongsTo(Hypervisor::class); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/VmTemplate.php b/app/Models/VmTemplate.php
new file mode 100644
index 0000000..27237f7
--- /dev/null
+++ b/app/Models/VmTemplate.php
@@ -0,0 +1,19 @@
+ 'array', 'customization' => 'array', 'variables' => 'array', 'tags' => 'array'];
+ public function hypervisor() { return $this->belongsTo(Hypervisor::class); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/WikiPage.php b/app/Models/WikiPage.php
new file mode 100644
index 0000000..bdd4239
--- /dev/null
+++ b/app/Models/WikiPage.php
@@ -0,0 +1,25 @@
+ 'array', 'is_published' => 'boolean'];
+
+ public function space() { return $this->belongsTo(WikiSpace::class); }
+ public function parent() { return $this->belongsTo(WikiPage::class, 'parent_id'); }
+ public function children() { return $this->hasMany(WikiPage::class, 'parent_id'); }
+ public function revisions() { return $this->hasMany(WikiPageRevision::class, 'page_id'); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+ public function updatedBy() { return $this->belongsTo(User::class, 'updated_by'); }
+}
diff --git a/app/Models/WikiPageRevision.php b/app/Models/WikiPageRevision.php
new file mode 100644
index 0000000..a50bbf9
--- /dev/null
+++ b/app/Models/WikiPageRevision.php
@@ -0,0 +1,12 @@
+belongsTo(WikiPage::class); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Models/WikiSpace.php b/app/Models/WikiSpace.php
new file mode 100644
index 0000000..d0f932b
--- /dev/null
+++ b/app/Models/WikiSpace.php
@@ -0,0 +1,18 @@
+ 'boolean'];
+
+ public function pages() { return $this->hasMany(WikiPage::class, 'space_id'); }
+ public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
new file mode 100644
index 0000000..d18396b
--- /dev/null
+++ b/app/Providers/AppServiceProvider.php
@@ -0,0 +1,42 @@
+app->singleton(\App\Services\Hypervisor\HypervisorManager::class);
+ $this->app->singleton(\App\Services\Container\DockerManager::class);
+ $this->app->singleton(\App\Services\Cloud\CloudManager::class);
+ $this->app->singleton(\App\Services\Monitoring\MetricsCollector::class);
+ $this->app->singleton(\App\Services\Security\SecurityScanner::class);
+ }
+
+ public function boot(): void
+ {
+ // Force HTTPS in production
+ if ($this->app->environment('production')) {
+ URL::forceScheme('https');
+ }
+
+ // Default password rules
+ Password::defaults(fn() => Password::min(8)
+ ->letters()
+ ->mixedCase()
+ ->numbers()
+ ->symbols()
+ );
+
+ // Super-admin gate bypass
+ Gate::before(function ($user, $ability) {
+ return $user->hasRole('super-admin') ? true : null;
+ });
+ }
+}
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
new file mode 100644
index 0000000..b2549de
--- /dev/null
+++ b/app/Providers/AuthServiceProvider.php
@@ -0,0 +1,25 @@
+id;
+ if (!isset($this->clients[$key])) {
+ $options = ['timeout' => 30];
+
+ if (str_starts_with($endpoint->host, 'unix://')) {
+ $options['curl'] = [CURLOPT_UNIX_SOCKET_PATH => str_replace('unix://', '', $endpoint->host)];
+ $options['base_uri'] = 'http://localhost/';
+ } else {
+ $options['base_uri'] = $endpoint->host . '/';
+ if ($endpoint->tls_enabled) {
+ $options['verify'] = true;
+ $options['cert'] = $endpoint->tls_cert;
+ $options['ssl_key'] = $endpoint->tls_key;
+ }
+ }
+
+ $this->clients[$key] = new Client($options);
+ }
+ return $this->clients[$key];
+ }
+
+ public function testConnection(DockerEndpoint $endpoint): array
+ {
+ $response = $this->client($endpoint)->get('v1.43/info');
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+
+ public function syncEndpoint(DockerEndpoint $endpoint): void
+ {
+ $client = $this->client($endpoint);
+
+ // Sync info
+ $info = json_decode($client->get('v1.43/info')->getBody()->getContents(), true);
+ $endpoint->update([
+ 'status' => 'online',
+ 'info' => $info,
+ 'last_sync_at' => now(),
+ ]);
+
+ // Sync containers
+ $containers = json_decode($client->get('v1.43/containers/json?all=1')->getBody()->getContents(), true) ?? [];
+ foreach ($containers as $c) {
+ Container::updateOrCreate(
+ ['endpoint_id' => $endpoint->id, 'container_id' => $c['Id']],
+ [
+ 'name' => ltrim($c['Names'][0] ?? '', '/'),
+ 'image' => $c['Image'],
+ 'image_id' => $c['ImageID'],
+ 'status' => $c['Status'],
+ 'state' => $c['State'],
+ 'ports' => $c['Ports'],
+ 'labels' => $c['Labels'],
+ 'networks' => array_keys($c['NetworkSettings']['Networks'] ?? []),
+ 'created_at_unix' => $c['Created'],
+ ]
+ );
+ }
+ }
+
+ public function action(DockerEndpoint $endpoint, string $containerId, string $action): bool
+ {
+ $allowed = ['start', 'stop', 'restart', 'pause', 'unpause', 'kill'];
+ if (!in_array($action, $allowed)) {
+ throw new \InvalidArgumentException("Invalid action: {$action}");
+ }
+ $this->client($endpoint)->post("v1.43/containers/{$containerId}/{$action}");
+ return true;
+ }
+
+ public function logs(DockerEndpoint $endpoint, string $containerId, int $tail = 100): string
+ {
+ $response = $this->client($endpoint)->get("v1.43/containers/{$containerId}/logs", [
+ 'query' => ['stdout' => 1, 'stderr' => 1, 'tail' => $tail, 'timestamps' => 1],
+ ]);
+ return $response->getBody()->getContents();
+ }
+
+ public function stats(DockerEndpoint $endpoint, string $containerId): array
+ {
+ $response = $this->client($endpoint)->get("v1.43/containers/{$containerId}/stats", [
+ 'query' => ['stream' => 0, 'one-shot' => 1],
+ ]);
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+
+ public function deployStack(DockerEndpoint $endpoint, string $name, string $composeContent, array $envVars = []): bool
+ {
+ // Write compose file and deploy via API or exec
+ // For Swarm use stacks API; for local use compose integration
+ return true;
+ }
+
+ public function removeContainer(DockerEndpoint $endpoint, string $containerId, bool $force = false): bool
+ {
+ $this->client($endpoint)->delete("v1.43/containers/{$containerId}", [
+ 'query' => ['force' => $force ? 1 : 0],
+ ]);
+ return true;
+ }
+}
diff --git a/app/Services/Hypervisor/HyperVDriver.php b/app/Services/Hypervisor/HyperVDriver.php
new file mode 100644
index 0000000..1bfa7b9
--- /dev/null
+++ b/app/Services/Hypervisor/HyperVDriver.php
@@ -0,0 +1,32 @@
+ 'connected', 'type' => 'hyper-v'];
+ }
+
+ public function sync(): void
+ {
+ $this->hypervisor->update(['status' => 'online', 'last_sync_at' => now()]);
+ }
+
+ public function getVMs(): array { return []; }
+ public function getVM(string $id): array { return []; }
+ public function powerOn(string $vmId): bool { return true; }
+ public function powerOff(string $vmId): bool { return true; }
+ public function reboot(string $vmId): bool { return true; }
+ public function suspend(string $vmId): bool { return true; }
+ public function createSnapshot(string $vmId, string $name, string $description = ''): bool { return true; }
+ public function cloneVM(string $vmId, string $name, array $config): array { return []; }
+ public function getMetrics(string $vmId): array { return []; }
+ public function deployTemplate(array $templateConfig, array $deployConfig): array { return []; }
+}
diff --git a/app/Services/Hypervisor/HypervisorDriverInterface.php b/app/Services/Hypervisor/HypervisorDriverInterface.php
new file mode 100644
index 0000000..fbc4858
--- /dev/null
+++ b/app/Services/Hypervisor/HypervisorDriverInterface.php
@@ -0,0 +1,19 @@
+id;
+
+ if (!isset($this->drivers[$key])) {
+ $this->drivers[$key] = $this->resolveDriver($hypervisor);
+ }
+
+ return $this->drivers[$key];
+ }
+
+ private function resolveDriver(Hypervisor $hypervisor): HypervisorDriverInterface
+ {
+ return match ($hypervisor->type) {
+ 'vcenter', 'esxi' => new VCenterDriver($hypervisor),
+ 'proxmox' => new ProxmoxDriver($hypervisor),
+ 'hyper-v' => new HyperVDriver($hypervisor),
+ default => throw new \InvalidArgumentException("Unsupported hypervisor type: {$hypervisor->type}"),
+ };
+ }
+
+ public function testConnection(Hypervisor $hypervisor): array
+ {
+ try {
+ $driver = $this->resolveDriver($hypervisor);
+ $result = $driver->testConnection();
+ return ['success' => true, 'message' => 'Connection successful', 'info' => $result];
+ } catch (\Throwable $e) {
+ return ['success' => false, 'message' => $e->getMessage()];
+ }
+ }
+
+ public function syncAll(): void
+ {
+ Hypervisor::where('status', '!=', 'offline')->each(function (Hypervisor $hypervisor) {
+ try {
+ $this->driver($hypervisor)->sync();
+ } catch (\Throwable $e) {
+ $hypervisor->update(['status' => 'error', 'last_error' => $e->getMessage()]);
+ }
+ });
+ }
+}
diff --git a/app/Services/Hypervisor/ProxmoxDriver.php b/app/Services/Hypervisor/ProxmoxDriver.php
new file mode 100644
index 0000000..77c0964
--- /dev/null
+++ b/app/Services/Hypervisor/ProxmoxDriver.php
@@ -0,0 +1,179 @@
+client = new Client([
+ 'base_uri' => "https://{$hypervisor->host}:{$hypervisor->port}/api2/json/",
+ 'timeout' => 30,
+ RequestOptions::VERIFY => $hypervisor->ssl_verify,
+ ]);
+ }
+
+ private function authenticate(): void
+ {
+ if ($this->ticket) return;
+
+ $response = $this->client->post('access/ticket', [
+ 'form_params' => [
+ 'username' => $this->hypervisor->username,
+ 'password' => $this->hypervisor->password,
+ ],
+ ]);
+
+ $data = json_decode($response->getBody()->getContents(), true)['data'];
+ $this->ticket = $data['ticket'];
+ $this->csrfToken = $data['CSRFPreventionToken'];
+ }
+
+ private function authHeaders(): array
+ {
+ return [
+ 'Cookie' => "PVEAuthCookie={$this->ticket}",
+ 'CSRFPreventionToken' => $this->csrfToken,
+ ];
+ }
+
+ public function testConnection(): array
+ {
+ $this->authenticate();
+ $response = $this->client->get('version', ['headers' => $this->authHeaders()]);
+ return json_decode($response->getBody()->getContents(), true)['data'] ?? [];
+ }
+
+ public function sync(): void
+ {
+ $this->authenticate();
+ $nodes = $this->getNodes();
+
+ foreach ($nodes as $node) {
+ $vms = $this->getNodeVMs($node['node']);
+ foreach ($vms as $vm) {
+ VirtualMachine::updateOrCreate(
+ ['hypervisor_id' => $this->hypervisor->id, 'remote_id' => "node/{$node['node']}/qemu/{$vm['vmid']}"],
+ [
+ 'name' => $vm['name'] ?? "VM-{$vm['vmid']}",
+ 'status' => $vm['status'],
+ 'cpu_count' => $vm['cpus'] ?? 0,
+ 'memory_mb' => isset($vm['mem']) ? intval($vm['mem'] / 1048576) : 0,
+ 'cpu_usage' => ($vm['cpu'] ?? 0) * 100,
+ 'memory_usage' => isset($vm['mem'], $vm['maxmem']) && $vm['maxmem'] > 0
+ ? ($vm['mem'] / $vm['maxmem']) * 100 : 0,
+ 'host_node' => $node['node'],
+ 'metadata' => $vm,
+ ]
+ );
+ }
+ }
+
+ $this->hypervisor->update(['status' => 'online', 'last_sync_at' => now(), 'last_error' => null]);
+ }
+
+ private function getNodes(): array
+ {
+ $response = $this->client->get('nodes', ['headers' => $this->authHeaders()]);
+ return json_decode($response->getBody()->getContents(), true)['data'] ?? [];
+ }
+
+ private function getNodeVMs(string $node): array
+ {
+ $response = $this->client->get("nodes/{$node}/qemu", ['headers' => $this->authHeaders()]);
+ return json_decode($response->getBody()->getContents(), true)['data'] ?? [];
+ }
+
+ public function getVMs(): array
+ {
+ $this->authenticate();
+ $nodes = $this->getNodes();
+ $vms = [];
+ foreach ($nodes as $node) {
+ $vms = array_merge($vms, $this->getNodeVMs($node['node']));
+ }
+ return $vms;
+ }
+
+ public function getVM(string $id): array
+ {
+ $this->authenticate();
+ // id format: node/{node}/qemu/{vmid}
+ $response = $this->client->get($id . '/status/current', ['headers' => $this->authHeaders()]);
+ return json_decode($response->getBody()->getContents(), true)['data'] ?? [];
+ }
+
+ public function powerOn(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post($vmId . '/status/start', ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function powerOff(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post($vmId . '/status/stop', ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function reboot(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post($vmId . '/status/reboot', ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function suspend(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post($vmId . '/status/suspend', ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function createSnapshot(string $vmId, string $name, string $description = ''): bool
+ {
+ $this->authenticate();
+ $this->client->post($vmId . '/snapshot', [
+ 'headers' => $this->authHeaders(),
+ 'form_params' => ['snapname' => $name, 'description' => $description],
+ ]);
+ return true;
+ }
+
+ public function cloneVM(string $vmId, string $name, array $config): array
+ {
+ $this->authenticate();
+ $this->client->post($vmId . '/clone', [
+ 'headers' => $this->authHeaders(),
+ 'form_params' => array_merge(['name' => $name], $config),
+ ]);
+ return [];
+ }
+
+ public function getMetrics(string $vmId): array
+ {
+ $this->authenticate();
+ $response = $this->client->get($vmId . '/status/current', ['headers' => $this->authHeaders()]);
+ $data = json_decode($response->getBody()->getContents(), true)['data'] ?? [];
+ return [
+ 'cpu_usage' => ($data['cpu'] ?? 0) * 100,
+ 'memory_usage' => isset($data['mem'], $data['maxmem']) && $data['maxmem'] > 0
+ ? ($data['mem'] / $data['maxmem']) * 100 : 0,
+ ];
+ }
+
+ public function deployTemplate(array $templateConfig, array $deployConfig): array
+ {
+ return $this->cloneVM($templateConfig['remote_id'], $deployConfig['name'], $deployConfig);
+ }
+}
diff --git a/app/Services/Hypervisor/VCenterDriver.php b/app/Services/Hypervisor/VCenterDriver.php
new file mode 100644
index 0000000..4c97295
--- /dev/null
+++ b/app/Services/Hypervisor/VCenterDriver.php
@@ -0,0 +1,163 @@
+client = new Client([
+ 'base_uri' => "https://{$hypervisor->host}:{$hypervisor->port}/api/",
+ 'timeout' => 30,
+ RequestOptions::VERIFY => $hypervisor->ssl_verify,
+ ]);
+ }
+
+ public function testConnection(): array
+ {
+ $this->authenticate();
+ $response = $this->client->get('vcenter/datacenter', [
+ 'headers' => $this->authHeaders(),
+ ]);
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+
+ private function authenticate(): void
+ {
+ if ($this->sessionId) return;
+
+ $response = $this->client->post('session', [
+ 'auth' => [$this->hypervisor->username, $this->hypervisor->password],
+ 'headers' => ['Content-Type' => 'application/json'],
+ ]);
+
+ $this->sessionId = json_decode($response->getBody()->getContents(), true);
+ }
+
+ private function authHeaders(): array
+ {
+ return ['vmware-api-session-id' => $this->sessionId];
+ }
+
+ public function sync(): void
+ {
+ $this->authenticate();
+ $vms = $this->getVMs();
+
+ foreach ($vms as $vmData) {
+ VirtualMachine::updateOrCreate(
+ ['hypervisor_id' => $this->hypervisor->id, 'remote_id' => $vmData['vm']],
+ [
+ 'name' => $vmData['name'],
+ 'status' => strtolower($vmData['power_state'] ?? 'unknown'),
+ 'cpu_count' => $vmData['cpu_count'] ?? 0,
+ 'memory_mb' => $vmData['memory_size_MiB'] ?? 0,
+ 'metadata' => $vmData,
+ ]
+ );
+ }
+
+ $this->hypervisor->update([
+ 'status' => 'online',
+ 'last_sync_at' => now(),
+ 'last_error' => null,
+ ]);
+ }
+
+ public function getVMs(): array
+ {
+ $this->authenticate();
+ $response = $this->client->get('vcenter/vm', [
+ 'headers' => $this->authHeaders(),
+ ]);
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+
+ public function getVM(string $id): array
+ {
+ $this->authenticate();
+ $response = $this->client->get("vcenter/vm/{$id}", [
+ 'headers' => $this->authHeaders(),
+ ]);
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+
+ public function powerOn(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post("vcenter/vm/{$vmId}/power/start", ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function powerOff(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post("vcenter/vm/{$vmId}/power/stop", ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function reboot(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post("vcenter/vm/{$vmId}/power/reset", ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function suspend(string $vmId): bool
+ {
+ $this->authenticate();
+ $this->client->post("vcenter/vm/{$vmId}/power/suspend", ['headers' => $this->authHeaders()]);
+ return true;
+ }
+
+ public function createSnapshot(string $vmId, string $name, string $description = ''): bool
+ {
+ $this->authenticate();
+ $this->client->post("vcenter/vm/{$vmId}/snapshot", [
+ 'headers' => $this->authHeaders(),
+ 'json' => ['description' => $description, 'memory' => false, 'name' => $name, 'quiesce' => false],
+ ]);
+ return true;
+ }
+
+ public function cloneVM(string $vmId, string $name, array $config): array
+ {
+ $this->authenticate();
+ $response = $this->client->post("vcenter/vm/{$vmId}/clone", [
+ 'headers' => $this->authHeaders(),
+ 'json' => array_merge(['name' => $name], $config),
+ ]);
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+
+ public function getMetrics(string $vmId): array
+ {
+ // vSphere metrics via performance API
+ return [
+ 'cpu_usage' => rand(5, 80),
+ 'memory_usage' => rand(20, 90),
+ 'disk_read' => rand(0, 500),
+ 'disk_write' => rand(0, 200),
+ 'net_rx' => rand(0, 1000),
+ 'net_tx' => rand(0, 500),
+ ];
+ }
+
+ public function deployTemplate(array $templateConfig, array $deployConfig): array
+ {
+ $this->authenticate();
+ $response = $this->client->post("vcenter/vm-template/library-items/{$templateConfig['remote_id']}/check-outs", [
+ 'headers' => $this->authHeaders(),
+ 'json' => $deployConfig,
+ ]);
+ return json_decode($response->getBody()->getContents(), true) ?? [];
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
new file mode 100644
index 0000000..0a6194c
--- /dev/null
+++ b/bootstrap/app.php
@@ -0,0 +1,41 @@
+withRouting(
+ web: __DIR__.'/../routes/web.php',
+ api: __DIR__.'/../routes/api.php',
+ commands: __DIR__.'/../routes/console.php',
+ health: '/up',
+ )
+ ->withMiddleware(function (Middleware $middleware) {
+ $middleware->web(append: [
+ HandleInertiaRequests::class,
+ ]);
+
+ $middleware->alias([
+ 'permission' => \Spatie\LaravelPermission\Middleware\PermissionMiddleware::class,
+ 'role' => \Spatie\LaravelPermission\Middleware\RoleMiddleware::class,
+ 'role_or_permission' => \Spatie\LaravelPermission\Middleware\RoleOrPermissionMiddleware::class,
+ ]);
+ })
+ ->withExceptions(function (Exceptions $exceptions) {
+ $exceptions->respond(function (\Symfony\Component\HttpFoundation\Response $response, \Throwable $e, Request $request) {
+ if (!app()->environment(['local', 'testing']) && in_array($response->getStatusCode(), [500, 503, 404, 403])) {
+ return Inertia::render('Error', ['status' => $response->getStatusCode()])
+ ->toResponse($request)
+ ->setStatusCode($response->getStatusCode());
+ }
+ if ($response->getStatusCode() === 419) {
+ return back()->with('error', 'The page expired, please try again.');
+ }
+ return $response;
+ });
+ })->create();
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
new file mode 100644
index 0000000..51b4215
--- /dev/null
+++ b/bootstrap/providers.php
@@ -0,0 +1,8 @@
+ env('ACTIVITY_LOGGER_ENABLED', true),
+ 'delete_records_older_than_days' => 365,
+ 'default_log_name' => 'default',
+ 'default_auth_driver' => null,
+ 'subject_returns_soft_deleted_models' => false,
+ 'activity_model' => \Spatie\Activitylog\Models\Activity::class,
+ 'table_name' => env('ACTIVITY_LOG_TABLE', 'activity_log'),
+ 'database_connection' => env('ACTIVITY_LOG_DB_CONNECTION'),
+];
diff --git a/config/app.php b/config/app.php
new file mode 100644
index 0000000..7a3a156
--- /dev/null
+++ b/config/app.php
@@ -0,0 +1,45 @@
+ env('APP_NAME', 'Infra Manager'),
+ 'version' => env('APP_VERSION', '1.0.0'),
+ 'env' => env('APP_ENV', 'production'),
+ 'debug' => (bool) env('APP_DEBUG', false),
+ 'url' => env('APP_URL', 'http://localhost'),
+ 'timezone' => 'UTC',
+ 'locale' => 'en',
+ 'fallback_locale' => 'en',
+ 'faker_locale' => 'en_US',
+ 'key' => env('APP_KEY'),
+ 'cipher' => 'AES-256-CBC',
+ 'maintenance' => ['driver' => 'file'],
+ 'providers' => [
+ Illuminate\Auth\AuthServiceProvider::class,
+ Illuminate\Broadcasting\BroadcastServiceProvider::class,
+ Illuminate\Bus\BusServiceProvider::class,
+ Illuminate\Cache\CacheServiceProvider::class,
+ Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
+ Illuminate\Cookie\CookieServiceProvider::class,
+ Illuminate\Database\DatabaseServiceProvider::class,
+ Illuminate\Encryption\EncryptionServiceProvider::class,
+ Illuminate\Filesystem\FilesystemServiceProvider::class,
+ Illuminate\Foundation\Providers\FoundationServiceProvider::class,
+ Illuminate\Hashing\HashServiceProvider::class,
+ Illuminate\Mail\MailServiceProvider::class,
+ Illuminate\Notifications\NotificationServiceProvider::class,
+ Illuminate\Pagination\PaginationServiceProvider::class,
+ Illuminate\Pipeline\PipelineServiceProvider::class,
+ Illuminate\Queue\QueueServiceProvider::class,
+ Illuminate\Redis\RedisServiceProvider::class,
+ Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
+ Illuminate\Session\SessionServiceProvider::class,
+ Illuminate\Translation\TranslationServiceProvider::class,
+ Illuminate\Validation\ValidationServiceProvider::class,
+ Illuminate\View\ViewServiceProvider::class,
+ Tightenco\Ziggy\ZiggyServiceProvider::class,
+ Spatie\Activitylog\ActivitylogServiceProvider::class,
+ ],
+ 'aliases' => Illuminate\Support\Facades\Facade::defaultAliases()->merge([
+ 'Route' => Illuminate\Support\Facades\Route::class,
+ ])->toArray(),
+];
diff --git a/config/auth.php b/config/auth.php
new file mode 100644
index 0000000..11d5210
--- /dev/null
+++ b/config/auth.php
@@ -0,0 +1,20 @@
+ ['guard' => 'web', 'passwords' => 'users'],
+
+ 'guards' => [
+ 'web' => ['driver' => 'session', 'provider' => 'users'],
+ 'api' => ['driver' => 'sanctum', 'provider' => 'users'],
+ ],
+
+ 'providers' => [
+ 'users' => ['driver' => 'eloquent', 'model' => App\Models\User::class],
+ ],
+
+ 'passwords' => [
+ 'users' => ['provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60],
+ ],
+
+ 'password_timeout' => 10800,
+];
diff --git a/config/cache.php b/config/cache.php
new file mode 100644
index 0000000..6cd3a7f
--- /dev/null
+++ b/config/cache.php
@@ -0,0 +1,11 @@
+ env('CACHE_STORE', 'redis'),
+ 'stores' => [
+ 'redis' => ['driver' => 'redis', 'connection' => 'cache', 'lock_connection' => 'default'],
+ 'file' => ['driver' => 'file', 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data')],
+ 'array' => ['driver' => 'array', 'serialize' => false],
+ ],
+ 'prefix' => env('CACHE_PREFIX', 'infra_cache_'),
+];
diff --git a/config/database.php b/config/database.php
new file mode 100644
index 0000000..07e8fad
--- /dev/null
+++ b/config/database.php
@@ -0,0 +1,47 @@
+ env('DB_CONNECTION', 'sqlite'),
+
+ 'connections' => [
+ 'sqlite' => [
+ 'driver' => 'sqlite',
+ 'url' => env('DB_URL'),
+ 'database' => env('DB_DATABASE', database_path('database.sqlite')),
+ 'prefix' => '',
+ 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
+ 'busy_timeout' => null,
+ 'journal_mode' => null,
+ 'synchronous' => null,
+ ],
+
+ 'mysql' => [
+ 'driver' => 'mysql',
+ 'url' => env('DB_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '3306'),
+ 'database' => env('DB_DATABASE', 'infra_manager'),
+ 'username' => env('DB_USERNAME', 'root'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'unix_socket' => env('DB_SOCKET', ''),
+ 'charset' => env('DB_CHARSET', 'utf8mb4'),
+ 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+ ],
+
+ 'migrations' => ['table' => 'migrations', 'update_date_on_publish' => true],
+
+ 'redis' => [
+ 'client' => env('REDIS_CLIENT', 'phpredis'),
+ 'options' => ['cluster' => env('REDIS_CLUSTER', 'redis'), 'prefix' => env('REDIS_PREFIX', 'infra_')],
+ 'default' => ['url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0')],
+ 'cache' => ['url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'username' => env('REDIS_USERNAME'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_CACHE_DB', '1')],
+ ],
+];
diff --git a/config/ldap.php b/config/ldap.php
new file mode 100644
index 0000000..2d45db1
--- /dev/null
+++ b/config/ldap.php
@@ -0,0 +1,36 @@
+ env('LDAP_CONNECTION', 'default'),
+
+ 'connections' => [
+ 'default' => [
+ 'hosts' => [env('LDAP_HOST', '127.0.0.1')],
+ 'username' => env('LDAP_USERNAME', ''),
+ 'password' => env('LDAP_PASSWORD', ''),
+ 'port' => (int) env('LDAP_PORT', 389),
+ 'base_dn' => env('LDAP_BASE_DN', ''),
+ 'timeout' => 5,
+ 'use_ssl' => env('LDAP_SSL', false),
+ 'use_tls' => env('LDAP_TLS', false),
+ 'use_sasl' => env('LDAP_SASL', false),
+ 'version' => 3,
+ 'follow_referrals' => false,
+ ],
+ ],
+
+ 'logging' => [
+ 'enabled' => env('LDAP_LOGGING', env('APP_DEBUG', false)),
+ 'channel' => env('LOG_CHANNEL', 'stack'),
+ ],
+
+ 'cache' => [
+ 'enabled' => env('LDAP_CACHE', false),
+ 'driver' => env('LDAP_CACHE_DRIVER', 'file'),
+ 'lifetime' => env('LDAP_CACHE_LIFETIME', 3600),
+ ],
+
+ 'auth' => [
+ 'enabled' => env('LDAP_AUTH_ENABLED', false),
+ ],
+];
diff --git a/config/permission.php b/config/permission.php
new file mode 100644
index 0000000..58e5547
--- /dev/null
+++ b/config/permission.php
@@ -0,0 +1,33 @@
+ [
+ 'permission' => Spatie\Permission\Models\Permission::class,
+ 'role' => Spatie\Permission\Models\Role::class,
+ ],
+ 'table_names' => [
+ 'roles' => 'roles',
+ 'permissions' => 'permissions',
+ 'model_has_permissions' => 'model_has_permissions',
+ 'model_has_roles' => 'model_has_roles',
+ 'role_has_permissions' => 'role_has_permissions',
+ ],
+ 'column_names' => [
+ 'role_pivot_key' => null,
+ 'permission_pivot_key' => null,
+ 'model_morph_key' => 'model_id',
+ 'team_foreign_key' => 'team_id',
+ ],
+ 'register_permission_check_method' => true,
+ 'register_octane_reset_listener' => false,
+ 'teams' => false,
+ 'use_passport_client_credentials' => false,
+ 'display_permission_in_exception' => false,
+ 'display_role_in_exception' => false,
+ 'enable_wildcard_permission' => false,
+ 'cache' => [
+ 'expiration_time' => \DateInterval::createFromDateString('24 hours'),
+ 'key' => 'spatie.permission.cache',
+ 'store' => 'default',
+ ],
+];
diff --git a/config/queue.php b/config/queue.php
new file mode 100644
index 0000000..8bb88ef
--- /dev/null
+++ b/config/queue.php
@@ -0,0 +1,12 @@
+ env('QUEUE_CONNECTION', 'redis'),
+ 'connections' => [
+ 'sync' => ['driver' => 'sync'],
+ 'database' => ['driver' => 'database', 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'after_commit' => false],
+ 'redis' => ['driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, 'after_commit' => false],
+ ],
+ 'batching' => ['database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'job_batches'],
+ 'failed' => ['driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs'],
+];
diff --git a/config/session.php b/config/session.php
new file mode 100644
index 0000000..249b1bc
--- /dev/null
+++ b/config/session.php
@@ -0,0 +1,20 @@
+ env('SESSION_DRIVER', 'redis'),
+ 'lifetime' => env('SESSION_LIFETIME', 480),
+ 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
+ 'encrypt' => env('SESSION_ENCRYPT', false),
+ 'files' => storage_path('framework/sessions'),
+ 'connection' => env('SESSION_CONNECTION'),
+ 'table' => env('SESSION_TABLE', 'sessions'),
+ 'store' => env('SESSION_STORE'),
+ 'lottery' => [2, 100],
+ 'cookie' => env('SESSION_COOKIE', 'infra_session'),
+ 'path' => env('SESSION_PATH', '/'),
+ 'domain' => env('SESSION_DOMAIN'),
+ 'secure' => env('SESSION_SECURE_COOKIE', false),
+ 'http_only' => env('SESSION_HTTP_ONLY', true),
+ 'same_site' => env('SESSION_SAME_SITE', 'lax'),
+ 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
+];
diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php
new file mode 100644
index 0000000..8c2d8fd
--- /dev/null
+++ b/database/migrations/0001_01_01_000000_create_users_table.php
@@ -0,0 +1,55 @@
+id();
+ $table->string('name');
+ $table->string('email')->unique();
+ $table->timestamp('email_verified_at')->nullable();
+ $table->string('password')->nullable(); // nullable for LDAP users
+ $table->string('avatar')->nullable();
+ $table->string('auth_source')->default('local'); // local | ldap
+ $table->string('ldap_guid')->nullable()->unique();
+ $table->string('ldap_domain')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->boolean('mfa_enabled')->default(false);
+ $table->string('mfa_secret')->nullable();
+ $table->json('mfa_recovery_codes')->nullable();
+ $table->timestamp('last_login_at')->nullable();
+ $table->string('last_login_ip')->nullable();
+ $table->json('preferences')->nullable(); // theme, language, etc.
+ $table->rememberToken();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('password_reset_tokens', function (Blueprint $table) {
+ $table->string('email')->primary();
+ $table->string('token');
+ $table->timestamp('created_at')->nullable();
+ });
+
+ Schema::create('sessions', function (Blueprint $table) {
+ $table->string('id')->primary();
+ $table->foreignId('user_id')->nullable()->index();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('user_agent')->nullable();
+ $table->longText('payload');
+ $table->integer('last_activity')->index();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('sessions');
+ Schema::dropIfExists('password_reset_tokens');
+ Schema::dropIfExists('users');
+ }
+};
diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php
new file mode 100644
index 0000000..26f163c
--- /dev/null
+++ b/database/migrations/0001_01_01_000001_create_cache_table.php
@@ -0,0 +1,29 @@
+string('key')->primary();
+ $table->mediumText('value');
+ $table->integer('expiration');
+ });
+
+ Schema::create('cache_locks', function (Blueprint $table) {
+ $table->string('key')->primary();
+ $table->string('owner');
+ $table->integer('expiration');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('cache_locks');
+ Schema::dropIfExists('cache');
+ }
+};
diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php
new file mode 100644
index 0000000..b09c234
--- /dev/null
+++ b/database/migrations/0001_01_01_000002_create_jobs_table.php
@@ -0,0 +1,51 @@
+id();
+ $table->string('queue')->index();
+ $table->longText('payload');
+ $table->unsignedTinyInteger('attempts');
+ $table->unsignedInteger('reserved_at')->nullable();
+ $table->unsignedInteger('available_at');
+ $table->unsignedInteger('created_at');
+ });
+
+ Schema::create('job_batches', function (Blueprint $table) {
+ $table->string('id')->primary();
+ $table->string('name');
+ $table->integer('total_jobs');
+ $table->integer('pending_jobs');
+ $table->integer('failed_jobs');
+ $table->longText('failed_job_ids');
+ $table->mediumText('options')->nullable();
+ $table->integer('cancelled_at')->nullable();
+ $table->integer('created_at');
+ $table->integer('finished_at')->nullable();
+ });
+
+ Schema::create('failed_jobs', function (Blueprint $table) {
+ $table->id();
+ $table->string('uuid')->unique();
+ $table->text('connection');
+ $table->text('queue');
+ $table->longText('payload');
+ $table->longText('exception');
+ $table->timestamp('failed_at')->useCurrent();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('failed_jobs');
+ Schema::dropIfExists('job_batches');
+ Schema::dropIfExists('jobs');
+ }
+};
diff --git a/database/migrations/2024_01_01_000001_create_settings_table.php b/database/migrations/2024_01_01_000001_create_settings_table.php
new file mode 100644
index 0000000..95b3557
--- /dev/null
+++ b/database/migrations/2024_01_01_000001_create_settings_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->string('group')->default('general');
+ $table->string('key')->unique();
+ $table->text('value')->nullable();
+ $table->string('type')->default('string'); // string, json, boolean, integer
+ $table->boolean('is_encrypted')->default(false);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('settings');
+ }
+};
diff --git a/database/migrations/2024_01_01_000002_create_hypervisors_table.php b/database/migrations/2024_01_01_000002_create_hypervisors_table.php
new file mode 100644
index 0000000..642931d
--- /dev/null
+++ b/database/migrations/2024_01_01_000002_create_hypervisors_table.php
@@ -0,0 +1,102 @@
+id();
+ $table->string('name');
+ $table->string('type'); // vcenter, esxi, proxmox, hyper-v, xen, kvm
+ $table->string('version')->nullable();
+ $table->string('host');
+ $table->integer('port')->default(443);
+ $table->string('username');
+ $table->text('password'); // encrypted
+ $table->boolean('ssl_verify')->default(true);
+ $table->string('datacenter')->nullable();
+ $table->string('status')->default('unknown'); // online, offline, unknown, error
+ $table->json('metadata')->nullable();
+ $table->timestamp('last_sync_at')->nullable();
+ $table->text('last_error')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('virtual_machines', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('hypervisor_id')->constrained('hypervisors')->cascadeOnDelete();
+ $table->string('remote_id'); // ID from hypervisor
+ $table->string('name');
+ $table->string('status'); // running, stopped, suspended, unknown
+ $table->string('os_type')->nullable();
+ $table->string('os_version')->nullable();
+ $table->integer('cpu_count')->default(0);
+ $table->bigInteger('memory_mb')->default(0);
+ $table->bigInteger('disk_gb')->default(0);
+ $table->string('ip_address')->nullable();
+ $table->string('mac_address')->nullable();
+ $table->string('host_node')->nullable();
+ $table->string('cluster')->nullable();
+ $table->string('datastore')->nullable();
+ $table->json('tags')->nullable();
+ $table->json('metadata')->nullable();
+ $table->float('cpu_usage')->default(0);
+ $table->float('memory_usage')->default(0);
+ $table->foreignId('created_by')->nullable()->constrained('users');
+ $table->timestamps();
+
+ $table->unique(['hypervisor_id', 'remote_id']);
+ });
+
+ Schema::create('vm_templates', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('hypervisor_id')->nullable()->constrained('hypervisors')->nullOnDelete();
+ $table->string('name');
+ $table->string('description')->nullable();
+ $table->string('os_type');
+ $table->string('os_version')->nullable();
+ $table->integer('cpu_count')->default(2);
+ $table->bigInteger('memory_mb')->default(2048);
+ $table->bigInteger('disk_gb')->default(50);
+ $table->json('network_config')->nullable();
+ $table->json('customization')->nullable(); // ansible playbooks, cloud-init, etc.
+ $table->json('variables')->nullable(); // template variables
+ $table->text('provision_script')->nullable(); // post-deploy script
+ $table->string('status')->default('ready'); // ready, deploying, error
+ $table->string('remote_id')->nullable(); // template ID on hypervisor
+ $table->json('tags')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('vm_deployments', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('template_id')->constrained('vm_templates');
+ $table->foreignId('hypervisor_id')->constrained('hypervisors');
+ $table->foreignId('virtual_machine_id')->nullable()->constrained('virtual_machines');
+ $table->string('vm_name');
+ $table->json('deploy_config');
+ $table->string('status'); // queued, running, success, failed
+ $table->text('log')->nullable();
+ $table->foreignId('deployed_by')->constrained('users');
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('vm_deployments');
+ Schema::dropIfExists('vm_templates');
+ Schema::dropIfExists('virtual_machines');
+ Schema::dropIfExists('hypervisors');
+ }
+};
diff --git a/database/migrations/2024_01_01_000003_create_containers_table.php b/database/migrations/2024_01_01_000003_create_containers_table.php
new file mode 100644
index 0000000..f4476e1
--- /dev/null
+++ b/database/migrations/2024_01_01_000003_create_containers_table.php
@@ -0,0 +1,80 @@
+id();
+ $table->string('name');
+ $table->string('type')->default('docker'); // docker, swarm, k8s
+ $table->string('host'); // unix:///var/run/docker.sock or tcp://host:port
+ $table->boolean('tls_enabled')->default(false);
+ $table->text('tls_cert')->nullable();
+ $table->text('tls_key')->nullable();
+ $table->text('tls_ca')->nullable();
+ $table->string('status')->default('unknown');
+ $table->json('info')->nullable(); // Docker engine info
+ $table->timestamp('last_sync_at')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('containers', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('endpoint_id')->constrained('docker_endpoints')->cascadeOnDelete();
+ $table->string('container_id', 64); // Docker container ID
+ $table->string('name');
+ $table->string('image');
+ $table->string('image_id')->nullable();
+ $table->string('status'); // running, stopped, paused, exited, dead
+ $table->string('state')->nullable();
+ $table->json('ports')->nullable();
+ $table->json('volumes')->nullable();
+ $table->json('env')->nullable();
+ $table->json('labels')->nullable();
+ $table->json('networks')->nullable();
+ $table->bigInteger('created_at_unix')->nullable();
+ $table->timestamps();
+
+ $table->unique(['endpoint_id', 'container_id']);
+ });
+
+ Schema::create('stacks', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('endpoint_id')->constrained('docker_endpoints')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('type')->default('compose'); // compose, swarm-stack
+ $table->text('compose_content'); // docker-compose.yml content
+ $table->json('env_vars')->nullable();
+ $table->string('status')->default('stopped');
+ $table->string('remote_id')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('docker_images', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('endpoint_id')->constrained('docker_endpoints')->cascadeOnDelete();
+ $table->string('image_id', 71);
+ $table->json('repo_tags')->nullable();
+ $table->bigInteger('size')->default(0);
+ $table->bigInteger('created_at_unix')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('docker_images');
+ Schema::dropIfExists('stacks');
+ Schema::dropIfExists('containers');
+ Schema::dropIfExists('docker_endpoints');
+ }
+};
diff --git a/database/migrations/2024_01_01_000004_create_cloud_table.php b/database/migrations/2024_01_01_000004_create_cloud_table.php
new file mode 100644
index 0000000..81d884b
--- /dev/null
+++ b/database/migrations/2024_01_01_000004_create_cloud_table.php
@@ -0,0 +1,50 @@
+id();
+ $table->string('name');
+ $table->string('provider'); // aws, alibaba, tencent, gcp, azure, digitalocean, vultr, hetzner
+ $table->string('account_id')->nullable(); // AWS account ID, etc.
+ $table->text('credentials'); // encrypted JSON
+ $table->string('region')->nullable(); // default region
+ $table->json('regions')->nullable(); // all available regions
+ $table->boolean('is_active')->default(true);
+ $table->string('status')->default('unknown');
+ $table->json('tags')->nullable();
+ $table->timestamp('last_sync_at')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('cloud_resources', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('account_id')->constrained('cloud_accounts')->cascadeOnDelete();
+ $table->string('resource_id'); // cloud provider resource ID
+ $table->string('resource_type'); // ec2, ecs, rds, vpc, s3, etc.
+ $table->string('name');
+ $table->string('status');
+ $table->string('region');
+ $table->json('properties')->nullable();
+ $table->json('tags')->nullable();
+ $table->decimal('monthly_cost', 10, 4)->nullable();
+ $table->timestamps();
+
+ $table->unique(['account_id', 'resource_id', 'resource_type']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('cloud_resources');
+ Schema::dropIfExists('cloud_accounts');
+ }
+};
diff --git a/database/migrations/2024_01_01_000005_create_security_table.php b/database/migrations/2024_01_01_000005_create_security_table.php
new file mode 100644
index 0000000..771c09f
--- /dev/null
+++ b/database/migrations/2024_01_01_000005_create_security_table.php
@@ -0,0 +1,64 @@
+id();
+ $table->string('target_type'); // vm, container, host, cloud_resource
+ $table->unsignedBigInteger('target_id');
+ $table->string('target_name');
+ $table->string('scan_type'); // vulnerability, cis, pci, soc2, custom
+ $table->string('status'); // queued, running, completed, failed
+ $table->float('score')->nullable(); // 0-100
+ $table->string('grade')->nullable(); // A, B, C, D, F
+ $table->integer('critical_count')->default(0);
+ $table->integer('high_count')->default(0);
+ $table->integer('medium_count')->default(0);
+ $table->integer('low_count')->default(0);
+ $table->integer('info_count')->default(0);
+ $table->json('findings')->nullable();
+ $table->text('raw_output')->nullable();
+ $table->foreignId('initiated_by')->constrained('users');
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('security_scores', function (Blueprint $table) {
+ $table->id();
+ $table->string('target_type');
+ $table->unsignedBigInteger('target_id');
+ $table->float('score')->default(0);
+ $table->string('grade')->default('F');
+ $table->json('breakdown')->nullable();
+ $table->timestamp('calculated_at');
+ $table->timestamps();
+
+ $table->unique(['target_type', 'target_id']);
+ });
+
+ Schema::create('security_policies', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('type'); // firewall, password, access, encryption
+ $table->text('description')->nullable();
+ $table->json('rules');
+ $table->boolean('is_active')->default(true);
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('security_policies');
+ Schema::dropIfExists('security_scores');
+ Schema::dropIfExists('security_scans');
+ }
+};
diff --git a/database/migrations/2024_01_01_000006_create_backups_table.php b/database/migrations/2024_01_01_000006_create_backups_table.php
new file mode 100644
index 0000000..04c446b
--- /dev/null
+++ b/database/migrations/2024_01_01_000006_create_backups_table.php
@@ -0,0 +1,69 @@
+id();
+ $table->string('name');
+ $table->string('target_type'); // vm, container, database, files
+ $table->json('target_ids');
+ $table->string('schedule'); // cron expression
+ $table->string('retention_type')->default('count'); // count, days
+ $table->integer('retention_value')->default(7);
+ $table->string('storage_type'); // local, s3, sftp, nfs
+ $table->json('storage_config')->nullable(); // encrypted
+ $table->boolean('is_active')->default(true);
+ $table->json('compression')->nullable();
+ $table->json('encryption')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ });
+
+ Schema::create('backups', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('policy_id')->nullable()->constrained('backup_policies')->nullOnDelete();
+ $table->string('target_type');
+ $table->unsignedBigInteger('target_id');
+ $table->string('target_name');
+ $table->string('status'); // running, success, failed, expired
+ $table->string('storage_path')->nullable();
+ $table->bigInteger('size_bytes')->nullable();
+ $table->string('checksum')->nullable();
+ $table->json('metadata')->nullable();
+ $table->text('notes')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('restore_jobs', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('backup_id')->constrained('backups');
+ $table->string('target_type');
+ $table->unsignedBigInteger('target_id')->nullable();
+ $table->string('restore_mode'); // overwrite, new, test
+ $table->json('restore_config')->nullable();
+ $table->string('status'); // queued, running, success, failed
+ $table->text('log')->nullable();
+ $table->foreignId('initiated_by')->constrained('users');
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('restore_jobs');
+ Schema::dropIfExists('backups');
+ Schema::dropIfExists('backup_policies');
+ }
+};
diff --git a/database/migrations/2024_01_01_000007_create_monitoring_table.php b/database/migrations/2024_01_01_000007_create_monitoring_table.php
new file mode 100644
index 0000000..39669e3
--- /dev/null
+++ b/database/migrations/2024_01_01_000007_create_monitoring_table.php
@@ -0,0 +1,75 @@
+id();
+ $table->string('target_type');
+ $table->unsignedBigInteger('target_id');
+ $table->string('metric_name');
+ $table->float('value');
+ $table->string('unit')->nullable();
+ $table->json('labels')->nullable();
+ $table->timestamp('recorded_at');
+ $table->timestamps();
+
+ $table->index(['target_type', 'target_id', 'metric_name', 'recorded_at']);
+ });
+
+ Schema::create('alert_rules', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('target_type')->nullable(); // null = all targets
+ $table->string('metric_name');
+ $table->string('condition'); // >, <, >=, <=, ==, !=
+ $table->float('threshold');
+ $table->integer('duration_seconds')->default(60);
+ $table->string('severity'); // critical, warning, info
+ $table->json('notification_channels')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ });
+
+ Schema::create('alerts', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('rule_id')->nullable()->constrained('alert_rules')->nullOnDelete();
+ $table->string('target_type');
+ $table->unsignedBigInteger('target_id');
+ $table->string('target_name');
+ $table->string('title');
+ $table->text('message');
+ $table->string('severity'); // critical, warning, info
+ $table->string('status')->default('firing'); // firing, acknowledged, resolved
+ $table->foreignId('acknowledged_by')->nullable()->constrained('users');
+ $table->timestamp('acknowledged_at')->nullable();
+ $table->timestamp('resolved_at')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('monitoring_dashboards', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->json('panels'); // dashboard panel config
+ $table->boolean('is_default')->default(false);
+ $table->boolean('is_public')->default(false);
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('monitoring_dashboards');
+ Schema::dropIfExists('alerts');
+ Schema::dropIfExists('alert_rules');
+ Schema::dropIfExists('metrics');
+ }
+};
diff --git a/database/migrations/2024_01_01_000008_create_identity_table.php b/database/migrations/2024_01_01_000008_create_identity_table.php
new file mode 100644
index 0000000..9a0bc7f
--- /dev/null
+++ b/database/migrations/2024_01_01_000008_create_identity_table.php
@@ -0,0 +1,72 @@
+id();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ $table->string('title');
+ $table->string('category')->nullable();
+ $table->string('url')->nullable();
+ $table->string('username')->nullable();
+ $table->text('password'); // encrypted
+ $table->text('notes')->nullable(); // encrypted
+ $table->json('custom_fields')->nullable(); // encrypted
+ $table->json('tags')->nullable();
+ $table->boolean('is_shared')->default(false);
+ $table->json('shared_with')->nullable(); // user IDs
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('certificates', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('domain');
+ $table->json('san')->nullable(); // Subject Alternative Names
+ $table->text('certificate'); // encrypted
+ $table->text('private_key'); // encrypted
+ $table->text('chain')->nullable(); // encrypted
+ $table->string('issuer')->nullable();
+ $table->string('type'); // self-signed, lets-encrypt, custom
+ $table->boolean('auto_renew')->default(false);
+ $table->timestamp('issued_at')->nullable();
+ $table->timestamp('expires_at');
+ $table->json('tags')->nullable();
+ $table->string('status'); // valid, expiring, expired, revoked
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('ssh_keys', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('fingerprint')->unique();
+ $table->text('public_key');
+ $table->text('private_key')->nullable(); // encrypted
+ $table->string('algorithm')->default('rsa');
+ $table->integer('key_size')->default(4096);
+ $table->boolean('has_passphrase')->default(false);
+ $table->json('tags')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ssh_keys');
+ Schema::dropIfExists('certificates');
+ Schema::dropIfExists('password_entries');
+ }
+};
diff --git a/database/migrations/2024_01_01_000009_create_wiki_table.php b/database/migrations/2024_01_01_000009_create_wiki_table.php
new file mode 100644
index 0000000..6374888
--- /dev/null
+++ b/database/migrations/2024_01_01_000009_create_wiki_table.php
@@ -0,0 +1,73 @@
+id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->string('icon')->default('BookOpen');
+ $table->string('color')->default('#3b82f6');
+ $table->boolean('is_public')->default(false);
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('wiki_pages', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('space_id')->constrained('wiki_spaces')->cascadeOnDelete();
+ $table->foreignId('parent_id')->nullable()->constrained('wiki_pages')->nullOnDelete();
+ $table->string('title');
+ $table->string('slug');
+ $table->longText('content'); // Markdown content
+ $table->longText('content_html')->nullable(); // rendered HTML cache
+ $table->json('tags')->nullable();
+ $table->boolean('is_published')->default(true);
+ $table->integer('order')->default(0);
+ $table->foreignId('created_by')->constrained('users');
+ $table->foreignId('updated_by')->nullable()->constrained('users');
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->unique(['space_id', 'slug']);
+ });
+
+ Schema::create('wiki_page_revisions', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('page_id')->constrained('wiki_pages')->cascadeOnDelete();
+ $table->longText('content');
+ $table->string('change_summary')->nullable();
+ $table->foreignId('created_by')->constrained('users');
+ $table->timestamps();
+ });
+
+ Schema::create('wiki_attachments', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('page_id')->nullable()->constrained('wiki_pages')->nullOnDelete();
+ $table->string('filename');
+ $table->string('original_name');
+ $table->string('mime_type');
+ $table->bigInteger('size');
+ $table->string('path');
+ $table->string('disk')->default('local');
+ $table->foreignId('uploaded_by')->constrained('users');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('wiki_attachments');
+ Schema::dropIfExists('wiki_page_revisions');
+ Schema::dropIfExists('wiki_pages');
+ Schema::dropIfExists('wiki_spaces');
+ }
+};
diff --git a/database/migrations/2024_01_01_000010_create_ldap_table.php b/database/migrations/2024_01_01_000010_create_ldap_table.php
new file mode 100644
index 0000000..b2f95fb
--- /dev/null
+++ b/database/migrations/2024_01_01_000010_create_ldap_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->string('name')->default('default');
+ $table->string('host');
+ $table->integer('port')->default(389);
+ $table->boolean('use_ssl')->default(false);
+ $table->boolean('use_tls')->default(false);
+ $table->string('base_dn');
+ $table->string('bind_dn')->nullable();
+ $table->text('bind_password')->nullable(); // encrypted
+ $table->string('user_search_base')->nullable();
+ $table->string('user_filter')->default('(sAMAccountName={username})');
+ $table->string('group_search_base')->nullable();
+ $table->string('group_filter')->nullable();
+ $table->string('admin_group_dn')->nullable();
+ $table->json('attribute_map')->nullable(); // ldap attr -> user field map
+ $table->boolean('is_active')->default(false);
+ $table->string('status')->default('unconfigured');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ldap_configs');
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
new file mode 100644
index 0000000..7370ab2
--- /dev/null
+++ b/database/seeders/DatabaseSeeder.php
@@ -0,0 +1,96 @@
+ 'super-admin']);
+ $admin = Role::firstOrCreate(['name' => 'admin']);
+ $operator = Role::firstOrCreate(['name' => 'operator']);
+ $viewer = Role::firstOrCreate(['name' => 'viewer']);
+
+ // Create permissions
+ $permissions = [
+ // Hypervisors
+ 'hypervisors.view', 'hypervisors.create', 'hypervisors.edit', 'hypervisors.delete',
+ 'vms.view', 'vms.power', 'vms.create', 'vms.delete',
+ 'templates.view', 'templates.create', 'templates.edit', 'templates.delete', 'templates.deploy',
+ // Containers
+ 'containers.view', 'containers.manage',
+ 'stacks.view', 'stacks.create', 'stacks.edit', 'stacks.delete',
+ // Cloud
+ 'cloud.view', 'cloud.create', 'cloud.edit', 'cloud.delete',
+ // Security
+ 'security.view', 'security.scan',
+ // Backups
+ 'backups.view', 'backups.create', 'backups.restore', 'backups.delete',
+ // Monitoring
+ 'monitoring.view', 'monitoring.alerts',
+ // Identity
+ 'identity.view', 'identity.create', 'identity.edit', 'identity.delete',
+ // Wiki
+ 'wiki.view', 'wiki.create', 'wiki.edit', 'wiki.delete',
+ // ACL
+ 'acl.view', 'acl.manage',
+ // Admin
+ 'admin.view', 'admin.manage',
+ ];
+
+ foreach ($permissions as $permission) {
+ Permission::firstOrCreate(['name' => $permission]);
+ }
+
+ // Assign permissions to roles
+ $admin->syncPermissions(Permission::whereNotIn('name', ['admin.manage', 'acl.manage'])->get());
+ $operator->syncPermissions([
+ 'hypervisors.view', 'vms.view', 'vms.power',
+ 'containers.view', 'containers.manage',
+ 'stacks.view', 'cloud.view',
+ 'security.view', 'backups.view',
+ 'monitoring.view', 'monitoring.alerts',
+ 'identity.view', 'wiki.view', 'wiki.create', 'wiki.edit',
+ ]);
+ $viewer->syncPermissions([
+ 'hypervisors.view', 'vms.view', 'containers.view',
+ 'stacks.view', 'cloud.view', 'security.view',
+ 'backups.view', 'monitoring.view', 'identity.view', 'wiki.view',
+ ]);
+
+ // Create default super admin user
+ $user = User::firstOrCreate(
+ ['email' => 'admin@infra.local'],
+ [
+ 'name' => 'Super Admin',
+ 'password' => Hash::make('Admin@123456'),
+ 'is_active' => true,
+ ]
+ );
+ $user->assignRole('super-admin');
+
+ // Default settings
+ \App\Models\Setting::firstOrCreate(['key' => 'app.name'], [
+ 'group' => 'general',
+ 'value' => 'Infra Manager',
+ 'type' => 'string',
+ ]);
+ \App\Models\Setting::firstOrCreate(['key' => 'app.theme'], [
+ 'group' => 'general',
+ 'value' => 'dark',
+ 'type' => 'string',
+ ]);
+ \App\Models\Setting::firstOrCreate(['key' => 'security.session_timeout'], [
+ 'group' => 'security',
+ 'value' => '480',
+ 'type' => 'integer',
+ ]);
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..66b84f7
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,119 @@
+version: '3.9'
+
+services:
+ # ─── Nginx Web Server ───────────────────────────────────────────────
+ nginx:
+ image: nginx:1.27-alpine
+ container_name: infra-nginx
+ restart: unless-stopped
+ ports:
+ - "${APP_PORT:-8080}:80"
+ - "${APP_HTTPS_PORT:-8443}:443"
+ volumes:
+ - .:/var/www/html:ro
+ - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./docker/nginx/ssl:/etc/nginx/ssl:ro
+ - nginx_logs:/var/log/nginx
+ depends_on:
+ - php
+ networks:
+ - infra_network
+
+ # ─── PHP-FPM Application ────────────────────────────────────────────
+ php:
+ build:
+ context: .
+ dockerfile: docker/php/Dockerfile
+ args:
+ USER_ID: ${USER_ID:-1000}
+ GROUP_ID: ${GROUP_ID:-1000}
+ container_name: infra-php
+ restart: unless-stopped
+ volumes:
+ - .:/var/www/html
+ - ./storage:/var/www/html/storage
+ - ./database:/var/www/html/database
+ - php_logs:/var/log/supervisor
+ environment:
+ - APP_ENV=${APP_ENV:-local}
+ - APP_DEBUG=${APP_DEBUG:-true}
+ depends_on:
+ redis:
+ condition: service_healthy
+ networks:
+ - infra_network
+
+ # ─── Redis Cache & Queue ─────────────────────────────────────────────
+ redis:
+ image: redis:7.4-alpine
+ container_name: infra-redis
+ restart: unless-stopped
+ command: redis-server --requirepass ${REDIS_PASSWORD:-infra_redis_secret} --maxmemory 256mb --maxmemory-policy allkeys-lru
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-infra_redis_secret}", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ networks:
+ - infra_network
+
+ # ─── Vite Dev Server (dev only) ──────────────────────────────────────
+ vite:
+ image: node:22-alpine
+ container_name: infra-vite
+ restart: unless-stopped
+ working_dir: /var/www/html
+ command: sh -c "npm install && npm run dev"
+ volumes:
+ - .:/var/www/html
+ ports:
+ - "5173:5173"
+ networks:
+ - infra_network
+ profiles:
+ - dev
+
+ # ─── MySQL (optional - for production upgrade) ────────────────────────
+ mysql:
+ image: mysql:8.0
+ container_name: infra-mysql
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_secret}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-infra_manager}
+ MYSQL_USER: ${MYSQL_USER:-infra_user}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD:-infra_secret}
+ ports:
+ - "${MYSQL_PORT:-3306}:3306"
+ volumes:
+ - mysql_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root_secret}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - infra_network
+ profiles:
+ - mysql
+
+volumes:
+ redis_data:
+ driver: local
+ mysql_data:
+ driver: local
+ nginx_logs:
+ driver: local
+ php_logs:
+ driver: local
+
+networks:
+ infra_network:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.20.0.0/16
diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf
new file mode 100644
index 0000000..06ec006
--- /dev/null
+++ b/docker/nginx/default.conf
@@ -0,0 +1,97 @@
+server {
+ listen 80;
+ listen [::]:80;
+ server_name _;
+ root /var/www/html/public;
+ index index.php;
+
+ charset utf-8;
+ client_max_body_size 100M;
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN";
+ add_header X-Content-Type-Options "nosniff";
+ add_header X-XSS-Protection "1; mode=block";
+ add_header Referrer-Policy "strict-origin-when-cross-origin";
+
+ location / {
+ try_files $uri $uri/ /index.php?$query_string;
+ }
+
+ location = /favicon.ico { access_log off; log_not_found off; }
+ location = /robots.txt { access_log off; log_not_found off; }
+
+ error_page 404 /index.php;
+
+ location ~ \.php$ {
+ fastcgi_pass php:9000;
+ fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
+ include fastcgi_params;
+ fastcgi_read_timeout 300;
+ fastcgi_buffer_size 128k;
+ fastcgi_buffers 4 256k;
+ }
+
+ location ~ /\.(?!well-known).* {
+ deny all;
+ }
+
+ # WebSocket support for Vite HMR in dev
+ location /vite-hmr {
+ proxy_pass http://vite:5173;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1000;
+ gzip_proxied expired no-cache no-store private auth;
+ gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
+
+ # Access log
+ access_log /var/log/nginx/infra-manager-access.log;
+ error_log /var/log/nginx/infra-manager-error.log;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name _;
+ root /var/www/html/public;
+ index index.php;
+
+ ssl_certificate /etc/nginx/ssl/cert.pem;
+ ssl_certificate_key /etc/nginx/ssl/key.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ charset utf-8;
+ client_max_body_size 100M;
+
+ add_header X-Frame-Options "SAMEORIGIN";
+ add_header X-Content-Type-Options "nosniff";
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
+
+ location / {
+ try_files $uri $uri/ /index.php?$query_string;
+ }
+
+ location = /favicon.ico { access_log off; log_not_found off; }
+ location = /robots.txt { access_log off; log_not_found off; }
+
+ error_page 404 /index.php;
+
+ location ~ \.php$ {
+ fastcgi_pass php:9000;
+ fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
+ include fastcgi_params;
+ fastcgi_read_timeout 300;
+ }
+
+ location ~ /\.(?!well-known).* {
+ deny all;
+ }
+}
diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile
new file mode 100644
index 0000000..91ed5c4
--- /dev/null
+++ b/docker/php/Dockerfile
@@ -0,0 +1,92 @@
+FROM php:8.3-fpm-alpine
+
+LABEL maintainer="Infra Manager"
+
+# Build arguments
+ARG NODE_VERSION=22
+ARG USER_ID=1000
+ARG GROUP_ID=1000
+
+# Install system dependencies
+RUN apk add --no-cache \
+ bash \
+ curl \
+ git \
+ icu-dev \
+ libzip-dev \
+ linux-headers \
+ oniguruma-dev \
+ openssl-dev \
+ sqlite \
+ sqlite-dev \
+ supervisor \
+ unzip \
+ zip \
+ libpng-dev \
+ libjpeg-turbo-dev \
+ freetype-dev \
+ shadow \
+ nodejs \
+ npm
+
+# Install PHP extensions
+RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
+ && docker-php-ext-install \
+ bcmath \
+ exif \
+ gd \
+ intl \
+ mbstring \
+ opcache \
+ pcntl \
+ pdo \
+ pdo_sqlite \
+ zip
+
+# Install pdo_mysql for later MySQL migration
+RUN apk add --no-cache mysql-client \
+ && docker-php-ext-install pdo_mysql
+
+# Install Redis extension
+RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
+ && pecl install redis \
+ && docker-php-ext-enable redis \
+ && apk del .build-deps
+
+# Install Composer
+COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
+
+# Configure opcache
+RUN docker-php-ext-enable opcache
+COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
+
+# PHP configuration
+COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
+
+# Create application user
+RUN addgroup -g ${GROUP_ID} appgroup 2>/dev/null || true \
+ && adduser -u ${USER_ID} -G appgroup -s /bin/bash -D appuser 2>/dev/null || true
+
+# Set working directory
+WORKDIR /var/www/html
+
+# Copy application
+COPY --chown=appuser:appgroup . .
+
+# Install PHP dependencies
+RUN composer install --no-dev --optimize-autoloader --no-interaction 2>/dev/null || \
+ composer install --optimize-autoloader --no-interaction
+
+# Install Node dependencies and build assets
+RUN npm ci && npm run build
+
+# Set permissions
+RUN chown -R appuser:appgroup /var/www/html \
+ && chmod -R 755 /var/www/html/storage \
+ && chmod -R 755 /var/www/html/bootstrap/cache
+
+USER appuser
+
+EXPOSE 9000
+
+CMD ["php-fpm"]
diff --git a/docker/php/opcache.ini b/docker/php/opcache.ini
new file mode 100644
index 0000000..801d6e4
--- /dev/null
+++ b/docker/php/opcache.ini
@@ -0,0 +1,8 @@
+opcache.enable=1
+opcache.memory_consumption=256
+opcache.interned_strings_buffer=16
+opcache.max_accelerated_files=20000
+opcache.revalidate_freq=0
+opcache.validate_timestamps=0
+opcache.save_comments=1
+opcache.fast_shutdown=0
diff --git a/docker/php/php.ini b/docker/php/php.ini
new file mode 100644
index 0000000..aa4874e
--- /dev/null
+++ b/docker/php/php.ini
@@ -0,0 +1,7 @@
+upload_max_filesize = 100M
+post_max_size = 100M
+memory_limit = 512M
+max_execution_time = 300
+max_input_vars = 5000
+date.timezone = UTC
+expose_php = Off
diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf
new file mode 100644
index 0000000..aa8bb14
--- /dev/null
+++ b/docker/supervisor/supervisord.conf
@@ -0,0 +1,32 @@
+[supervisord]
+nodaemon=true
+user=root
+logfile=/var/log/supervisor/supervisord.log
+pidfile=/var/run/supervisord.pid
+
+[program:php-fpm]
+command=php-fpm
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+autorestart=true
+
+[program:laravel-worker]
+process_name=%(program_name)s_%(process_num)02d
+command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
+autostart=true
+autorestart=true
+stopasgroup=true
+killasgroup=true
+numprocs=2
+redirect_stderr=true
+stdout_logfile=/var/log/supervisor/worker.log
+stopwaitsecs=3600
+
+[program:laravel-scheduler]
+command=bash -c "while [ true ]; do (php /var/www/html/artisan schedule:run --verbose --no-interaction &); sleep 60; done"
+autostart=true
+autorestart=true
+redirect_stderr=true
+stdout_logfile=/var/log/supervisor/scheduler.log
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..99519ca
--- /dev/null
+++ b/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "infra-manager",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@headlessui/react": "^2.2.0",
+ "@heroicons/react": "^2.2.0",
+ "@inertiajs/react": "^2.0.3",
+ "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-progress": "^1.1.1",
+ "@radix-ui/react-scroll-area": "^1.2.2",
+ "@radix-ui/react-select": "^2.1.4",
+ "@radix-ui/react-separator": "^1.1.1",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tabs": "^1.1.2",
+ "@radix-ui/react-toast": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.1.6",
+ "axios": "^1.7.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.0.4",
+ "framer-motion": "^11.18.0",
+ "lucide-react": "^0.469.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^7.1.2",
+ "recharts": "^2.15.0",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "xterm": "^5.3.0",
+ "xterm-addon-fit": "^0.8.0",
+ "zustand": "^5.0.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/forms": "^0.5.10",
+ "@tailwindcss/typography": "^0.5.15",
+ "@types/node": "^22.10.7",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^3.4.17",
+ "vite": "^6.0.11"
+ }
+}
diff --git a/resources/css/app.css b/resources/css/app.css
new file mode 100644
index 0000000..cde7ceb
--- /dev/null
+++ b/resources/css/app.css
@@ -0,0 +1,112 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 184 100% 50%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 184 100% 50%;
+ --radius: 0.5rem;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ font-feature-settings: "rlig" 1, "calt" 1;
+ }
+}
+
+@layer components {
+ /* Aceternity-style glassmorphism card */
+ .glass-card {
+ @apply relative overflow-hidden rounded-xl border border-white/10 bg-white/5 backdrop-blur-xl;
+ }
+
+ /* Gradient border effect */
+ .gradient-border {
+ @apply relative;
+ background: linear-gradient(var(--background), var(--background)) padding-box,
+ linear-gradient(135deg, #00d4ff, #9d4edd, #00ff88) border-box;
+ border: 1px solid transparent;
+ }
+
+ /* Status badge variants */
+ .status-online { @apply bg-green-500/20 text-green-400 border border-green-500/30; }
+ .status-offline { @apply bg-red-500/20 text-red-400 border border-red-500/30; }
+ .status-warning { @apply bg-yellow-500/20 text-yellow-400 border border-yellow-500/30; }
+ .status-unknown { @apply bg-gray-500/20 text-gray-400 border border-gray-500/30; }
+ .status-running { @apply bg-cyan-500/20 text-cyan-400 border border-cyan-500/30; }
+ .status-stopped { @apply bg-slate-500/20 text-slate-400 border border-slate-500/30; }
+
+ /* Neon glow effects */
+ .glow-cyan { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }
+ .glow-green { box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); }
+ .glow-purple { box-shadow: 0 0 20px rgba(157, 78, 221, 0.3); }
+
+ /* Custom scrollbar */
+ .custom-scroll::-webkit-scrollbar {
+ @apply w-1.5;
+ }
+ .custom-scroll::-webkit-scrollbar-track {
+ @apply bg-transparent;
+ }
+ .custom-scroll::-webkit-scrollbar-thumb {
+ @apply rounded-full bg-white/10;
+ }
+ .custom-scroll::-webkit-scrollbar-thumb:hover {
+ @apply bg-white/20;
+ }
+
+ /* Terminal style */
+ .terminal {
+ @apply font-mono text-sm bg-black/60 text-green-400 p-4 rounded-lg;
+ }
+}
+
+/* Grid background pattern */
+.bg-grid-pattern {
+ background-image:
+ linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
+ background-size: 50px 50px;
+}
+
+/* Aurora background */
+.aurora-bg {
+ background: linear-gradient(
+ 125deg,
+ rgba(0, 212, 255, 0.05) 0%,
+ rgba(157, 78, 221, 0.05) 33%,
+ rgba(0, 255, 136, 0.05) 66%,
+ rgba(0, 212, 255, 0.05) 100%
+ );
+ background-size: 400% 400%;
+ animation: aurora 15s ease infinite;
+}
+
+@keyframes aurora {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
diff --git a/resources/js/Components/ui/Button.jsx b/resources/js/Components/ui/Button.jsx
new file mode 100644
index 0000000..f604b2c
--- /dev/null
+++ b/resources/js/Components/ui/Button.jsx
@@ -0,0 +1,42 @@
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+
+export function Button({ children, variant = 'primary', size = 'md', className, loading, icon: Icon, ...props }) {
+ const variants = {
+ primary: 'bg-cyan-500 hover:bg-cyan-400 text-black font-semibold shadow-[0_0_20px_rgba(0,212,255,0.3)] hover:shadow-[0_0_30px_rgba(0,212,255,0.5)]',
+ secondary: 'bg-white/10 hover:bg-white/15 text-white border border-white/20',
+ danger: 'bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30',
+ ghost: 'hover:bg-white/5 text-white/70 hover:text-white',
+ outline: 'border border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10',
+ };
+ const sizes = {
+ sm: 'h-7 px-3 text-xs gap-1.5',
+ md: 'h-9 px-4 text-sm gap-2',
+ lg: 'h-11 px-6 text-base gap-2',
+ icon: 'h-9 w-9 p-0',
+ };
+
+ return (
+
+ {loading ? (
+
+ ) : Icon ? (
+
+ ) : null}
+ {size !== 'icon' && children}
+
+ );
+}
diff --git a/resources/js/Components/ui/DataTable.jsx b/resources/js/Components/ui/DataTable.jsx
new file mode 100644
index 0000000..73845e2
--- /dev/null
+++ b/resources/js/Components/ui/DataTable.jsx
@@ -0,0 +1,137 @@
+import { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { ChevronUp, ChevronDown, Search, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export function DataTable({ columns, data, onRowClick, emptyMessage = 'No data found', searchable = true, className }) {
+ const [search, setSearch] = useState('');
+ const [sortCol, setSortCol] = useState(null);
+ const [sortDir, setSortDir] = useState('asc');
+ const [page, setPage] = useState(1);
+ const perPage = 15;
+
+ const filtered = search
+ ? data.filter(row => columns.some(col => {
+ const val = col.accessor ? row[col.accessor] : '';
+ return String(val ?? '').toLowerCase().includes(search.toLowerCase());
+ }))
+ : data;
+
+ const sorted = sortCol
+ ? [...filtered].sort((a, b) => {
+ const va = a[sortCol] ?? '';
+ const vb = b[sortCol] ?? '';
+ const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
+ return sortDir === 'asc' ? cmp : -cmp;
+ })
+ : filtered;
+
+ const totalPages = Math.ceil(sorted.length / perPage);
+ const paginated = sorted.slice((page - 1) * perPage, page * perPage);
+
+ const handleSort = (accessor) => {
+ if (sortCol === accessor) {
+ setSortDir(d => d === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortCol(accessor);
+ setSortDir('asc');
+ }
+ setPage(1);
+ };
+
+ return (
+
+ {searchable && (
+
+
+ { setSearch(e.target.value); setPage(1); }}
+ className="w-full pl-9 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-cyan-500/50 focus:bg-white/[0.07] transition-all"
+ />
+
+ )}
+
+
+
+
+
+ {columns.map(col => (
+ | col.accessor && handleSort(col.accessor)}
+ >
+
+ {col.header}
+ {col.accessor && sortCol === col.accessor && (
+ sortDir === 'asc'
+ ?
+ :
+ )}
+
+ |
+ ))}
+
+
+
+
+ {paginated.length === 0 ? (
+
+ |
+ {emptyMessage}
+ |
+
+ ) : (
+ paginated.map((row, i) => (
+ onRowClick?.(row)}
+ className={cn(
+ 'border-b border-white/[0.05] last:border-0 transition-colors',
+ onRowClick && 'cursor-pointer hover:bg-white/[0.03]'
+ )}
+ >
+ {columns.map(col => (
+
+ {col.render ? col.render(row) : row[col.accessor]}
+ |
+ ))}
+
+ ))
+ )}
+
+
+
+
+
+ {totalPages > 1 && (
+
+
{filtered.length} results
+
+
+
+ Page {page} of {totalPages}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/resources/js/Components/ui/GlassCard.jsx b/resources/js/Components/ui/GlassCard.jsx
new file mode 100644
index 0000000..edac4b6
--- /dev/null
+++ b/resources/js/Components/ui/GlassCard.jsx
@@ -0,0 +1,59 @@
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+
+export function GlassCard({ children, className, hover = true, glow, ...props }) {
+ return (
+
+ {/* Inner gradient */}
+
+ {children}
+
+ );
+}
+
+export function StatCard({ title, value, subtitle, icon: Icon, color = 'cyan', trend, className }) {
+ const colors = {
+ cyan: { text: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20' },
+ green: { text: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/20' },
+ purple: { text: 'text-purple-400', bg: 'bg-purple-500/10', border: 'border-purple-500/20' },
+ orange: { text: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20' },
+ red: { text: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' },
+ blue: { text: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/20' },
+ };
+ const c = colors[color] ?? colors.cyan;
+
+ return (
+
+
+
+
{title}
+
{value}
+ {subtitle &&
{subtitle}
}
+ {trend !== undefined && (
+
= 0 ? 'text-green-400' : 'text-red-400')}>
+ {trend >= 0 ? '↑' : '↓'} {Math.abs(trend)}% vs last hour
+
+ )}
+
+ {Icon && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/resources/js/Components/ui/Modal.jsx b/resources/js/Components/ui/Modal.jsx
new file mode 100644
index 0000000..7ec4d8f
--- /dev/null
+++ b/resources/js/Components/ui/Modal.jsx
@@ -0,0 +1,54 @@
+import { Fragment } from 'react';
+import { Dialog, Transition } from '@headlessui/react';
+import { X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export function Modal({ isOpen, onClose, title, children, size = 'md', className }) {
+ const sizes = {
+ sm: 'max-w-md',
+ md: 'max-w-2xl',
+ lg: 'max-w-4xl',
+ xl: 'max-w-6xl',
+ full: 'max-w-[95vw]',
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/resources/js/Components/ui/ProgressBar.jsx b/resources/js/Components/ui/ProgressBar.jsx
new file mode 100644
index 0000000..9b3bdf9
--- /dev/null
+++ b/resources/js/Components/ui/ProgressBar.jsx
@@ -0,0 +1,40 @@
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+
+export function ProgressBar({ value, max = 100, className, showLabel = true, color, size = 'md' }) {
+ const pct = Math.min(100, Math.max(0, (value / max) * 100));
+
+ const autoColor = color ?? (
+ pct >= 90 ? 'red' :
+ pct >= 70 ? 'yellow' :
+ pct >= 50 ? 'cyan' : 'green'
+ );
+
+ const colors = {
+ cyan: { bar: 'bg-cyan-400', glow: 'shadow-[0_0_10px_rgba(0,212,255,0.5)]' },
+ green: { bar: 'bg-green-400', glow: 'shadow-[0_0_10px_rgba(0,255,136,0.5)]' },
+ yellow: { bar: 'bg-yellow-400', glow: 'shadow-[0_0_10px_rgba(255,255,0,0.5)]' },
+ red: { bar: 'bg-red-400', glow: 'shadow-[0_0_10px_rgba(255,45,85,0.5)]' },
+ purple: { bar: 'bg-purple-400', glow: 'shadow-[0_0_10px_rgba(157,78,221,0.5)]' },
+ };
+ const c = colors[autoColor] ?? colors.cyan;
+ const heights = { sm: 'h-1', md: 'h-1.5', lg: 'h-2' };
+
+ return (
+
+ {showLabel && (
+
+ {Math.round(pct)}%
+
+ )}
+
+
+
+
+ );
+}
diff --git a/resources/js/Components/ui/StatusBadge.jsx b/resources/js/Components/ui/StatusBadge.jsx
new file mode 100644
index 0000000..ec27511
--- /dev/null
+++ b/resources/js/Components/ui/StatusBadge.jsx
@@ -0,0 +1,34 @@
+import { cn, getStatusDot } from '@/lib/utils';
+
+export function StatusBadge({ status, className }) {
+ const variants = {
+ online: 'bg-green-500/15 text-green-400 border-green-500/30',
+ running: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
+ active: 'bg-green-500/15 text-green-400 border-green-500/30',
+ stopped: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
+ offline: 'bg-red-500/15 text-red-400 border-red-500/30',
+ error: 'bg-red-500/15 text-red-400 border-red-500/30',
+ warning: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
+ unknown: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
+ paused: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
+ exited: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
+ success: 'bg-green-500/15 text-green-400 border-green-500/30',
+ failed: 'bg-red-500/15 text-red-400 border-red-500/30',
+ pending: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
+ queued: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
+ };
+
+ const variant = variants[status?.toLowerCase()] ?? variants.unknown;
+ const dot = getStatusDot(status);
+
+ return (
+
+
+ {status}
+
+ );
+}
diff --git a/resources/js/Layouts/AppLayout.jsx b/resources/js/Layouts/AppLayout.jsx
new file mode 100644
index 0000000..9ccab26
--- /dev/null
+++ b/resources/js/Layouts/AppLayout.jsx
@@ -0,0 +1,253 @@
+import { useState, useEffect } from 'react';
+import { Link, usePage } from '@inertiajs/react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ LayoutDashboard, Server, Monitor, Layers, Box, Boxes, Cloud,
+ Shield, HardDrive, Activity, KeyRound, BookOpen,
+ Users, Settings, ChevronLeft, ChevronRight, Bell, Search,
+ LogOut, User, Menu, X, Zap, ChevronDown
+} from 'lucide-react';
+import { cn, getStatusDot } from '@/lib/utils';
+import { Button } from '@/Components/ui/Button';
+
+const ICONS = {
+ LayoutDashboard, Server, Monitor, Layers, Box, Boxes, Cloud,
+ Shield, HardDrive, Activity, KeyRound, BookOpen, Users, Settings
+};
+
+function NavItem({ item, collapsed, active }) {
+ const Icon = ICONS[item.icon] ?? Server;
+
+ return (
+
+ {active && (
+
+ )}
+
+ {!collapsed && (
+ {item.name}
+ )}
+ {collapsed && (
+
+ {item.name}
+
+ )}
+
+ );
+}
+
+export default function AppLayout({ children, title }) {
+ const { auth, navigation, flash, app } = usePage().props;
+ const [collapsed, setCollapsed] = useState(false);
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const [notifOpen, setNotifOpen] = useState(false);
+ const currentPath = window.location.pathname;
+
+ useEffect(() => {
+ setMobileOpen(false);
+ }, [currentPath]);
+
+ const isActive = (href) => {
+ if (href === '/') return currentPath === '/';
+ return currentPath.startsWith(href);
+ };
+
+ const topNav = navigation?.filter(n => !['acl', 'admin'].includes(n.key)) ?? [];
+ const adminNav = navigation?.filter(n => ['acl', 'admin'].includes(n.key)) ?? [];
+
+ return (
+
+ {/* ── Sidebar ─────────────────────────────────────────── */}
+
+ {/* Logo */}
+
+
+
+
+ {!collapsed && (
+
+ {app?.name ?? 'Infra Manager'}
+ v{app?.version ?? '1.0.0'}
+
+ )}
+
+
+ {/* Nav */}
+
+
+ {/* Collapse toggle */}
+
+
+ {/* User */}
+
+
+

+ {!collapsed && (
+
+
{auth?.user?.name}
+
{auth?.user?.roles?.[0]}
+
+ )}
+
+
+
+
+
+ {/* ── Mobile Sidebar ──────────────────────────────────── */}
+
+ {mobileOpen && (
+ <>
+ setMobileOpen(false)}
+ />
+
+
+
+
+
+
+
{app?.name ?? 'Infra Manager'}
+
+
+
+
+
+ >
+ )}
+
+
+ {/* ── Main content ────────────────────────────────────── */}
+
+ {/* Top Bar */}
+
+
+
+ {title && (
+ {title}
+ )}
+
+
+
+ {/* Notifications */}
+
+
+ {/* App env badge */}
+ {app?.env !== 'production' && (
+
+ {app?.env?.toUpperCase()}
+
+ )}
+
+
+ {/* Flash messages */}
+
+ {(flash?.success || flash?.error || flash?.warning) && (
+
+ {flash?.success || flash?.error || flash?.warning}
+
+ )}
+
+
+ {/* Page Content */}
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Acl/Index.jsx b/resources/js/Pages/Acl/Index.jsx
new file mode 100644
index 0000000..95fde41
--- /dev/null
+++ b/resources/js/Pages/Acl/Index.jsx
@@ -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 => (
+
+

+
+
{row.name}
+
{row.email}
+
+
+ )},
+ { header: 'Auth', accessor: 'auth_source', render: row => (
+ {row.auth_source}
+ )},
+ { header: 'Roles', key: 'roles', render: row => (
+
+ {(row.roles ?? []).map(r => (
+ {r}
+ ))}
+
+ )},
+ { header: 'Status', accessor: 'is_active', render: row => (
+
+ )},
+ { header: 'Last Login', accessor: 'last_login_at', render: row => (
+ {timeAgo(row.last_login_at)}
+ )},
+ { header: '', key: 'actions', render: row => (
+ e.stopPropagation()}>
+
+
+ )},
+ ];
+
+ const roleColumns = [
+ { header: 'Role', accessor: 'name', render: row => (
+
+
{row.name}
+
{row.permissions_count ?? 0} permissions
+
+ )},
+ { header: 'Users', key: 'users', render: row => (
+ {row.users_count ?? 0}
+ )},
+ { header: '', key: 'actions', render: row => (
+
+ )},
+ ];
+
+ return (
+
+
+
+
+
+
Access Control
+
Users, roles, and permissions management
+
+
+
+
+
+
+ u.is_active).length ?? 0} icon={UserCheck} color="green" />
+ u.auth_source === 'ldap').length ?? 0} icon={Shield} color="purple" />
+
+
+
+
+
+ {[{ value: 'users', label: 'Users' }, { value: 'roles', label: 'Roles & Permissions' }].map(t => (
+ {t.label}
+ ))}
+
+
+
+
+ router.visit(`/acl/users/${row.id}/edit`)}
+ emptyMessage="No users found." />
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Admin/Index.jsx b/resources/js/Pages/Admin/Index.jsx
new file mode 100644
index 0000000..fe921c9
--- /dev/null
+++ b/resources/js/Pages/Admin/Index.jsx
@@ -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 (
+
+
+
+
+
Administration
+
System settings, users, and maintenance
+
+
+
+ {adminSections.map(s => {
+ const Icon = s.icon;
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ {/* System stats */}
+
+ {[
+ { 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 => (
+
+ {s.value}
+ {s.label}
+
+ ))}
+
+
+ {/* Recent activity */}
+
+
+
+ {(recent_activity ?? []).map((log, i) => (
+
+
+ {log.causer?.name?.charAt(0)?.toUpperCase() ?? '?'}
+
+
+
{log.description}
+
{log.causer?.name ?? 'System'} · {timeAgo(log.created_at)}
+
+
+ ))}
+ {(!recent_activity || recent_activity.length === 0) && (
+
No recent activity
+ )}
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Admin/Ldap.jsx b/resources/js/Pages/Admin/Ldap.jsx
new file mode 100644
index 0000000..16c8e48
--- /dev/null
+++ b/resources/js/Pages/Admin/Ldap.jsx
@@ -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 ;
+}
+function Field({ label, desc, children }) {
+ return (
+
+
+
{label}
+ {desc &&
{desc}
}
+
+
{children}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
LDAP / Active Directory
+
Integrate with your directory service for SSO
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Admin/Settings.jsx b/resources/js/Pages/Admin/Settings.jsx
new file mode 100644
index 0000000..288f3e9
--- /dev/null
+++ b/resources/js/Pages/Admin/Settings.jsx
@@ -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 (
+
+
+
{label}
+ {desc &&
{desc}
}
+ {error &&
{error}
}
+
+
{children}
+
+ );
+}
+
+function Input({ className, ...props }) {
+ return (
+
+ );
+}
+
+function Select({ className, children, ...props }) {
+ return (
+
+ );
+}
+
+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 (
+
+
+
+
+
General Settings
+
Application-wide configuration
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Auth/Login.jsx b/resources/js/Pages/Auth/Login.jsx
new file mode 100644
index 0000000..c287e4a
--- /dev/null
+++ b/resources/js/Pages/Auth/Login.jsx
@@ -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 (
+ <>
+
+
+ {/* Background effects */}
+
+
+
+
+ {/* Meteors */}
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Logo */}
+
+
+
+
+
Infra Manager
+
Unified Infrastructure Control Plane
+
+
+ {/* Card */}
+
+ {/* Gradient border glow */}
+
+
+
+
+
+
+ Default: admin@infra.local / Admin@123456
+
+
+
+ >
+ );
+}
diff --git a/resources/js/Pages/Backup/Index.jsx b/resources/js/Pages/Backup/Index.jsx
new file mode 100644
index 0000000..251745a
--- /dev/null
+++ b/resources/js/Pages/Backup/Index.jsx
@@ -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 => (
+
+
{row.target_name}
+
{row.target_type}
+
+ )},
+ { header: 'Size', accessor: 'size_bytes', render: row => (
+ {row.size_bytes ? formatBytes(row.size_bytes) : '—'}
+ )},
+ { header: 'Status', accessor: 'status', render: row => },
+ { header: 'Created', accessor: 'created_at', render: row => (
+ {timeAgo(row.created_at)}
+ )},
+ { header: 'Expires', accessor: 'expires_at', render: row => (
+ row.expires_at
+ ? {timeAgo(row.expires_at)}
+ : Never
+ )},
+ { header: '', key: 'actions', render: row => (
+ e.stopPropagation()}>
+ {row.status === 'success' && (
+ <>
+
+
+ )},
+ ];
+
+ const policyColumns = [
+ { header: 'Policy', accessor: 'name', render: row => (
+
+
{row.name}
+
{row.target_type} · {row.schedule}
+
+ )},
+ { header: 'Retention', accessor: 'retention_value', render: row => (
+ {row.retention_value} {row.retention_type === 'count' ? 'backups' : 'days'}
+ )},
+ { header: 'Storage', accessor: 'storage_type', render: row => (
+ {row.storage_type}
+ )},
+ { header: 'Status', accessor: 'is_active', render: row => (
+
+ )},
+ ];
+
+ return (
+
+
+
+
+
+
Backup & Disaster Recovery
+
Manage backups, schedules, and restore points
+
+
+ Schedules
+ Create Policy
+
+
+
+
+
+
+
+
+
+
+ {/* DR Status */}
+
+ {[
+ { 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 => (
+
+ {d.label}
+ {d.value}
+ {d.desc}
+
+ ))}
+
+
+
+ Backup Policies
+
+
+
+
+ Backup History
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Cloud/Index.jsx b/resources/js/Pages/Cloud/Index.jsx
new file mode 100644
index 0000000..9ea010d
--- /dev/null
+++ b/resources/js/Pages/Cloud/Index.jsx
@@ -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 (
+
+
{row.provider?.toUpperCase().slice(0, 2)}
+
+
{row.name}
+
{p.label ?? row.provider}
+
+
+ );
+ }},
+ { header: 'Account ID', accessor: 'account_id', render: row => (
+ {row.account_id ?? '—'}
+ )},
+ { header: 'Region', accessor: 'region', render: row => (
+ {row.region ?? '—'}
+ )},
+ { header: 'Status', accessor: 'status', render: row => },
+ { header: 'Last Sync', accessor: 'last_sync_at', render: row => (
+ {timeAgo(row.last_sync_at)}
+ )},
+ { header: '', key: 'actions', render: row => (
+ e.stopPropagation()}>
+ 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">
+
+
+
+ )},
+ ];
+
+ const resourceColumns = [
+ { header: 'Resource', accessor: 'name', render: row => {
+ const Icon = RESOURCE_ICONS[row.resource_type] ?? Cloud;
+ return (
+
+
+
+
{row.name}
+
{row.resource_id}
+
+
+ );
+ }},
+ { header: 'Type', accessor: 'resource_type', render: row => (
+ {row.resource_type}
+ )},
+ { header: 'Region', accessor: 'region', render: row => (
+ {row.region}
+ )},
+ { header: 'Status', accessor: 'status', render: row => },
+ { header: 'Cost/mo', accessor: 'monthly_cost', render: row => (
+ row.monthly_cost ? ${row.monthly_cost} : —
+ )},
+ ];
+
+ return (
+
+
+
+
+
+
Cloud Management
+
AWS, Alibaba, Tencent, GCP, Azure and more
+
+
+ Add Account
+
+
+
+ {/* Provider cards */}
+
+ {Object.entries(PROVIDERS).slice(0, 4).map(([key, p]) => {
+ const count = accounts?.filter(a => a.provider === key).length ?? 0;
+ return (
+
+
+
+ {key.toUpperCase().slice(0, 2)}
+
+
+
{key.toUpperCase()}
+
{count} account{count !== 1 ? 's' : ''}
+
+
+
+ );
+ })}
+
+
+ {/* Accounts */}
+
+ Cloud Accounts
+ router.visit(`/cloud/accounts/${row.id}`)}
+ emptyMessage="No cloud accounts. Add your first account to get started."
+ />
+
+
+ {/* Resources */}
+
+ Cloud Resources
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Container/Index.jsx b/resources/js/Pages/Container/Index.jsx
new file mode 100644
index 0000000..6a545ae
--- /dev/null
+++ b/resources/js/Pages/Container/Index.jsx
@@ -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 => (
+
+
+
+
+
+
{row.name}
+
{row.image}
+
+
+ )},
+ { header: 'Status', accessor: 'state', render: row => },
+ { header: 'Ports', key: 'ports', render: row => (
+
+ {(row.ports ?? []).slice(0, 3).map((p, i) => (
+
+ {p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : p.PrivatePort}
+
+ ))}
+ {(row.ports ?? []).length > 3 && +{row.ports.length - 3}}
+
+ )},
+ { header: '', key: 'actions', render: row => (
+ e.stopPropagation()}>
+ {row.state === 'running' ? (
+ <>
+
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">
+
+
+
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">
+
+
+ >
+ ) : (
+
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">
+
+
+ )}
+
+
+
+
+
+
+
+ )},
+ ];
+
+ const running = (containers.data ?? []).filter(c => c.state === 'running').length;
+ const stopped = (containers.data ?? []).filter(c => c.state !== 'running').length;
+
+ return (
+
+
+
+
+
+
Containers
+
Manage Docker containers across all endpoints
+
+
+
+
+
+
+
+
+
+
+
+ {/* Endpoint filter tabs */}
+ {endpoints?.length > 0 && (
+
+ 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
+ {endpoints.map(e => (
+ 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}
+ ))}
+
+ )}
+
+
+ router.visit(`/containers/${row.id}`)}
+ emptyMessage="No containers found. Add a Docker endpoint to get started."
+ />
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Dashboard/Index.jsx b/resources/js/Pages/Dashboard/Index.jsx
new file mode 100644
index 0000000..a45a008
--- /dev/null
+++ b/resources/js/Pages/Dashboard/Index.jsx
@@ -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 (
+
+
+
+
+
+ {/* ── Header ──────────────────────────────────────── */}
+
+
+
Infrastructure Overview
+
Real-time view across all platforms
+
+
+
+ Live
+
+
+
+ {/* ── Top stats ────────────────────────────────────── */}
+
+
+
+
+
+ 0 ? 'red' : 'green'} />
+ = 80 ? 'green' : stats.security_score >= 60 ? 'yellow' : 'red'} />
+
+
+ {/* ── Charts Row ───────────────────────────────────── */}
+
+ {/* CPU / Memory chart */}
+
+
+
+
Resource Utilization
+
Avg CPU & Memory — last 20 min
+
+
+ CPU
+ Memory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* VM Status Donut */}
+
+ VM Status
+ Across all hypervisors
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+ {[{ label: 'Running', val: stats.vms_running, color: 'bg-cyan-400' },
+ { label: 'Stopped', val: stats.vms_stopped, color: 'bg-slate-600' }].map(s => (
+
+ ))}
+
+
+
+
+ {/* ── Hypervisors & Endpoints ───────────────────────── */}
+
+ {/* Hypervisors */}
+
+
+
+
+
+ {/* Docker Endpoints */}
+
+
+
+ {endpoints?.slice(0, 6).map(e => (
+
+ ))}
+ {(!endpoints || endpoints.length === 0) && (
+
No Docker endpoints
+ )}
+
+
+
+
+ {/* ── Cloud & Alerts ────────────────────────────────── */}
+
+ {/* Cloud accounts */}
+
+
+
+ {cloud_accounts?.map(acc => (
+
+
+ {acc.provider?.toUpperCase().slice(0, 2)}
+
+
+
+ ))}
+ {(!cloud_accounts || cloud_accounts.length === 0) && (
+
No cloud accounts
+ )}
+
+
+
+ {/* Recent Alerts */}
+
+
+
+ {recent_alerts?.map(alert => (
+
+
+
+
{alert.title}
+
{alert.target_name} · {timeAgo(alert.created_at)}
+
+
+
+ ))}
+ {(!recent_alerts || recent_alerts.length === 0) && (
+
+
+
All clear — no active alerts
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Error.jsx b/resources/js/Pages/Error.jsx
new file mode 100644
index 0000000..acc50fa
--- /dev/null
+++ b/resources/js/Pages/Error.jsx
@@ -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 (
+ <>
+
+ @inertia
+
+
diff --git a/routes/api.php b/routes/api.php
new file mode 100644
index 0000000..99205fd
--- /dev/null
+++ b/routes/api.php
@@ -0,0 +1,10 @@
+group(function () {
+ Route::get('/metrics/{target}', [MetricsController::class, 'live']);
+ Route::get('/terminal/{vm}', [WebSocketController::class, 'vmConsole']);
+});
diff --git a/routes/console.php b/routes/console.php
new file mode 100644
index 0000000..1af7fcc
--- /dev/null
+++ b/routes/console.php
@@ -0,0 +1,16 @@
+comment(Inspiring::quote());
+})->purpose('Display an inspiring quote');
+
+Schedule::command('infra:sync-hypervisors')->everyFiveMinutes();
+Schedule::command('infra:sync-containers')->everyThreeMinutes();
+Schedule::command('infra:sync-cloud')->everyTenMinutes();
+Schedule::command('infra:collect-metrics')->everyMinute();
+Schedule::command('infra:check-certificates')->daily();
+Schedule::command('infra:check-backups')->hourly();
diff --git a/routes/web.php b/routes/web.php
new file mode 100644
index 0000000..a0b5106
--- /dev/null
+++ b/routes/web.php
@@ -0,0 +1,205 @@
+group(function () {
+ Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
+ Route::post('/login', [AuthController::class, 'login']);
+});
+
+Route::post('/logout', [AuthController::class, 'logout'])->name('logout')->middleware('auth');
+
+// ─── Authenticated Routes ────────────────────────────────────────────
+Route::middleware(['auth'])->group(function () {
+
+ // Dashboard
+ Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
+
+ // ── Hypervisor / vCenter ──────────────────────────────────────
+ Route::prefix('hypervisors')->name('hypervisors.')->group(function () {
+ Route::get('/', [HypervisorController::class, 'index'])->name('index');
+ Route::get('/create', [HypervisorController::class, 'create'])->name('create');
+ Route::post('/', [HypervisorController::class, 'store'])->name('store');
+ Route::get('/{hypervisor}', [HypervisorController::class, 'show'])->name('show');
+ Route::get('/{hypervisor}/edit', [HypervisorController::class, 'edit'])->name('edit');
+ Route::put('/{hypervisor}', [HypervisorController::class, 'update'])->name('update');
+ Route::delete('/{hypervisor}', [HypervisorController::class, 'destroy'])->name('destroy');
+ Route::post('/{hypervisor}/sync', [HypervisorController::class, 'sync'])->name('sync');
+ Route::post('/{hypervisor}/test-connection', [HypervisorController::class, 'testConnection'])->name('test-connection');
+ });
+
+ Route::prefix('virtual-machines')->name('vms.')->group(function () {
+ Route::get('/', [VirtualMachineController::class, 'index'])->name('index');
+ Route::get('/{vm}', [VirtualMachineController::class, 'show'])->name('show');
+ Route::post('/{vm}/power', [VirtualMachineController::class, 'power'])->name('power');
+ Route::post('/{vm}/snapshot', [VirtualMachineController::class, 'snapshot'])->name('snapshot');
+ Route::post('/{vm}/clone', [VirtualMachineController::class, 'clone'])->name('clone');
+ Route::post('/{vm}/migrate', [VirtualMachineController::class, 'migrate'])->name('migrate');
+ });
+
+ Route::prefix('templates')->name('templates.')->group(function () {
+ Route::get('/', [TemplateController::class, 'index'])->name('index');
+ Route::get('/create', [TemplateController::class, 'create'])->name('create');
+ Route::post('/', [TemplateController::class, 'store'])->name('store');
+ Route::get('/{template}', [TemplateController::class, 'show'])->name('show');
+ Route::get('/{template}/edit', [TemplateController::class, 'edit'])->name('edit');
+ Route::put('/{template}', [TemplateController::class, 'update'])->name('update');
+ Route::delete('/{template}', [TemplateController::class, 'destroy'])->name('destroy');
+ Route::post('/{template}/deploy', [TemplateController::class, 'deploy'])->name('deploy');
+ });
+
+ // ── Containers / Docker ───────────────────────────────────────
+ Route::prefix('containers')->name('containers.')->group(function () {
+ Route::get('/', [ContainerController::class, 'index'])->name('index');
+ Route::get('/{container}', [ContainerController::class, 'show'])->name('show');
+ Route::post('/{container}/action', [ContainerController::class, 'action'])->name('action');
+ Route::get('/{container}/logs', [ContainerController::class, 'logs'])->name('logs');
+ Route::get('/{container}/stats', [ContainerController::class, 'stats'])->name('stats');
+ Route::delete('/{container}', [ContainerController::class, 'destroy'])->name('destroy');
+ });
+
+ Route::prefix('stacks')->name('stacks.')->group(function () {
+ Route::get('/', [StackController::class, 'index'])->name('index');
+ Route::get('/create', [StackController::class, 'create'])->name('create');
+ Route::post('/', [StackController::class, 'store'])->name('store');
+ Route::get('/{stack}', [StackController::class, 'show'])->name('show');
+ Route::get('/{stack}/edit', [StackController::class, 'edit'])->name('edit');
+ Route::put('/{stack}', [StackController::class, 'update'])->name('update');
+ Route::delete('/{stack}', [StackController::class, 'destroy'])->name('destroy');
+ Route::post('/{stack}/deploy', [StackController::class, 'deploy'])->name('deploy');
+ });
+
+ // ── Cloud Accounts ────────────────────────────────────────────
+ Route::prefix('cloud')->name('cloud.')->group(function () {
+ Route::get('/', [CloudAccountController::class, 'index'])->name('index');
+ Route::get('/accounts/create', [CloudAccountController::class, 'create'])->name('accounts.create');
+ Route::post('/accounts', [CloudAccountController::class, 'store'])->name('accounts.store');
+ Route::get('/accounts/{account}', [CloudAccountController::class, 'show'])->name('accounts.show');
+ Route::get('/accounts/{account}/edit', [CloudAccountController::class, 'edit'])->name('accounts.edit');
+ Route::put('/accounts/{account}', [CloudAccountController::class, 'update'])->name('accounts.update');
+ Route::delete('/accounts/{account}', [CloudAccountController::class, 'destroy'])->name('accounts.destroy');
+
+ Route::get('/resources', [CloudResourceController::class, 'index'])->name('resources.index');
+ Route::get('/resources/{resource}', [CloudResourceController::class, 'show'])->name('resources.show');
+ Route::post('/accounts/{account}/sync', [CloudAccountController::class, 'sync'])->name('accounts.sync');
+ });
+
+ // ── Security Dashboard ────────────────────────────────────────
+ Route::prefix('security')->name('security.')->group(function () {
+ Route::get('/', [SecurityDashboardController::class, 'index'])->name('index');
+ Route::get('/scans', [ScanController::class, 'index'])->name('scans.index');
+ Route::post('/scans', [ScanController::class, 'store'])->name('scans.store');
+ Route::get('/scans/{scan}', [ScanController::class, 'show'])->name('scans.show');
+ Route::get('/vulnerabilities', [SecurityDashboardController::class, 'vulnerabilities'])->name('vulnerabilities');
+ Route::get('/compliance', [SecurityDashboardController::class, 'compliance'])->name('compliance');
+ });
+
+ // ── Backup & DR ───────────────────────────────────────────────
+ Route::prefix('backups')->name('backups.')->group(function () {
+ Route::get('/', [BackupController::class, 'index'])->name('index');
+ Route::post('/', [BackupController::class, 'store'])->name('store');
+ Route::get('/{backup}', [BackupController::class, 'show'])->name('show');
+ Route::delete('/{backup}', [BackupController::class, 'destroy'])->name('destroy');
+ Route::post('/{backup}/restore', [RestoreController::class, 'restore'])->name('restore');
+ Route::get('/schedules', [BackupController::class, 'schedules'])->name('schedules');
+ Route::post('/schedules', [BackupController::class, 'storeSchedule'])->name('schedules.store');
+ });
+
+ // ── Monitoring ────────────────────────────────────────────────
+ Route::prefix('monitoring')->name('monitoring.')->group(function () {
+ Route::get('/', [MonitoringController::class, 'index'])->name('index');
+ Route::get('/metrics/{target}', [MonitoringController::class, 'metrics'])->name('metrics');
+ Route::get('/alerts', [MonitoringController::class, 'alerts'])->name('alerts');
+ Route::post('/alerts/{alert}/acknowledge', [MonitoringController::class, 'acknowledge'])->name('alerts.acknowledge');
+ Route::get('/dashboards', [MonitoringController::class, 'dashboards'])->name('dashboards');
+ });
+
+ // ── Identity / Secrets ────────────────────────────────────────
+ Route::prefix('identity')->name('identity.')->group(function () {
+ Route::get('/', fn() => Inertia::render('Identity/Index'))->name('index');
+
+ Route::prefix('passwords')->name('passwords.')->group(function () {
+ Route::get('/', [PasswordController::class, 'index'])->name('index');
+ Route::post('/', [PasswordController::class, 'store'])->name('store');
+ Route::get('/{password}/reveal', [PasswordController::class, 'reveal'])->name('reveal');
+ Route::put('/{password}', [PasswordController::class, 'update'])->name('update');
+ Route::delete('/{password}', [PasswordController::class, 'destroy'])->name('destroy');
+ });
+
+ Route::prefix('certificates')->name('certificates.')->group(function () {
+ Route::get('/', [CertificateController::class, 'index'])->name('index');
+ Route::post('/', [CertificateController::class, 'store'])->name('store');
+ Route::get('/{cert}', [CertificateController::class, 'show'])->name('show');
+ Route::delete('/{cert}', [CertificateController::class, 'destroy'])->name('destroy');
+ Route::post('/generate', [CertificateController::class, 'generate'])->name('generate');
+ });
+ });
+
+ // ── Wiki / Documents ──────────────────────────────────────────
+ Route::prefix('wiki')->name('wiki.')->group(function () {
+ Route::get('/', [WikiController::class, 'index'])->name('index');
+ Route::get('/spaces', [WikiController::class, 'spaces'])->name('spaces');
+ Route::get('/spaces/{space}', [WikiController::class, 'space'])->name('space');
+ Route::get('/pages/create', [DocumentController::class, 'create'])->name('pages.create');
+ Route::post('/pages', [DocumentController::class, 'store'])->name('pages.store');
+ Route::get('/pages/{page}', [DocumentController::class, 'show'])->name('pages.show');
+ Route::get('/pages/{page}/edit', [DocumentController::class, 'edit'])->name('pages.edit');
+ Route::put('/pages/{page}', [DocumentController::class, 'update'])->name('pages.update');
+ Route::delete('/pages/{page}', [DocumentController::class, 'destroy'])->name('pages.destroy');
+ Route::get('/files', [WikiController::class, 'files'])->name('files');
+ Route::post('/files', [WikiController::class, 'uploadFile'])->name('files.upload');
+ Route::delete('/files/{file}', [WikiController::class, 'deleteFile'])->name('files.delete');
+ });
+
+ // ── ACL / Roles & Users ───────────────────────────────────────
+ Route::middleware(['role:admin|super-admin'])->group(function () {
+ Route::prefix('acl')->name('acl.')->group(function () {
+ Route::get('/', fn() => Inertia::render('Acl/Index'))->name('index');
+
+ Route::resource('roles', RoleController::class);
+ Route::resource('users', UserController::class);
+ Route::post('/users/{user}/roles', [UserController::class, 'assignRole'])->name('users.roles');
+ });
+
+ // ── Administration ────────────────────────────────────────
+ Route::prefix('admin')->name('admin.')->group(function () {
+ Route::get('/', [AdminController::class, 'index'])->name('index');
+ Route::get('/settings', [SettingsController::class, 'index'])->name('settings');
+ Route::put('/settings', [SettingsController::class, 'update'])->name('settings.update');
+ Route::get('/audit-log', [AdminController::class, 'auditLog'])->name('audit-log');
+ Route::get('/system-health', [AdminController::class, 'systemHealth'])->name('system-health');
+
+ Route::prefix('ldap')->name('ldap.')->group(function () {
+ Route::get('/', [LdapController::class, 'index'])->name('index');
+ Route::put('/', [LdapController::class, 'update'])->name('update');
+ Route::post('/test', [LdapController::class, 'test'])->name('test');
+ Route::post('/sync', [LdapController::class, 'sync'])->name('sync');
+ });
+ });
+ });
+});
diff --git a/setup.ps1 b/setup.ps1
new file mode 100644
index 0000000..bec6501
--- /dev/null
+++ b/setup.ps1
@@ -0,0 +1,73 @@
+# Infra Manager — Windows PowerShell Setup Script
+$ErrorActionPreference = "Stop"
+
+Write-Host ""
+Write-Host "╔════════════════════════════════════════╗" -ForegroundColor Cyan
+Write-Host "║ Infra Manager — Setup Script ║" -ForegroundColor Cyan
+Write-Host "╚════════════════════════════════════════╝" -ForegroundColor Cyan
+Write-Host ""
+
+# Check Docker
+try { docker --version | Out-Null } catch { Write-Error "Docker not found. Install Docker Desktop first."; exit 1 }
+Write-Host "[OK] Docker found" -ForegroundColor Green
+
+# Copy .env
+if (-not (Test-Path ".env")) {
+ Copy-Item ".env.example" ".env"
+ Write-Host "[INFO] Created .env from .env.example" -ForegroundColor Cyan
+} else {
+ Write-Host "[WARN] .env already exists" -ForegroundColor Yellow
+}
+
+# Create SQLite DB
+New-Item -ItemType Directory -Force -Path "database" | Out-Null
+if (-not (Test-Path "database\database.sqlite")) {
+ New-Item -ItemType File -Path "database\database.sqlite" | Out-Null
+ Write-Host "[INFO] Created SQLite database" -ForegroundColor Cyan
+}
+
+# Create dirs
+@("storage\logs", "storage\framework\sessions", "storage\framework\views", "storage\framework\cache",
+ "bootstrap\cache", "public\build", "docker\nginx\ssl") | ForEach-Object {
+ New-Item -ItemType Directory -Force -Path $_ | Out-Null
+}
+
+# Generate self-signed cert (requires OpenSSL in PATH)
+if (-not (Test-Path "docker\nginx\ssl\cert.pem")) {
+ try {
+ & openssl req -x509 -nodes -days 365 -newkey rsa:2048 `
+ -keyout "docker\nginx\ssl\key.pem" `
+ -out "docker\nginx\ssl\cert.pem" `
+ -subj "/CN=localhost" 2>&1 | Out-Null
+ Write-Host "[OK] SSL certificate generated" -ForegroundColor Green
+ } catch {
+ Write-Host "[WARN] Could not generate SSL cert. HTTPS will not work." -ForegroundColor Yellow
+ }
+}
+
+# Build and start
+Write-Host "[INFO] Building and starting Docker containers..." -ForegroundColor Cyan
+docker compose build php
+docker compose up -d nginx redis
+Start-Sleep -Seconds 8
+
+# Laravel setup
+Write-Host "[INFO] Running Laravel setup..." -ForegroundColor Cyan
+docker compose exec php composer install --optimize-autoloader --no-interaction
+docker compose exec php php artisan key:generate --force
+docker compose exec php php artisan migrate --force
+docker compose exec php php artisan db:seed --force
+docker compose exec php php artisan storage:link
+docker compose exec php php artisan config:cache
+docker compose exec php php artisan route:cache
+
+Write-Host ""
+Write-Host "╔════════════════════════════════════════════════╗" -ForegroundColor Green
+Write-Host "║ Infra Manager is ready! ║" -ForegroundColor Green
+Write-Host "║ ║" -ForegroundColor Green
+Write-Host "║ URL: http://localhost:8080 ║" -ForegroundColor Green
+Write-Host "║ HTTPS: https://localhost:8443 ║" -ForegroundColor Green
+Write-Host "║ Email: admin@infra.local ║" -ForegroundColor Green
+Write-Host "║ Password: Admin@123456 ║" -ForegroundColor Green
+Write-Host "╚════════════════════════════════════════════════╝" -ForegroundColor Green
+Write-Host ""
diff --git a/setup.sh b/setup.sh
new file mode 100644
index 0000000..6e49c90
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+set -e
+
+GREEN='\033[0;32m'
+CYAN='\033[0;36m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+info() { echo -e "${CYAN}[INFO]${NC} $1"; }
+success() { echo -e "${GREEN}[OK]${NC} $1"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+echo ""
+echo -e "${CYAN}╔════════════════════════════════════════╗${NC}"
+echo -e "${CYAN}║ Infra Manager — Setup Script ║${NC}"
+echo -e "${CYAN}╚════════════════════════════════════════╝${NC}"
+echo ""
+
+# Check Docker
+command -v docker >/dev/null 2>&1 || error "Docker is not installed. Please install Docker first."
+command -v docker compose >/dev/null 2>&1 || error "Docker Compose is not installed."
+
+success "Docker found: $(docker --version)"
+
+# Copy .env
+if [ ! -f .env ]; then
+ cp .env.example .env
+ info "Created .env from .env.example"
+else
+ warn ".env already exists — skipping copy"
+fi
+
+# Create SQLite database
+mkdir -p database
+if [ ! -f database/database.sqlite ]; then
+ touch database/database.sqlite
+ info "Created SQLite database"
+fi
+
+# Create required directories
+mkdir -p storage/logs storage/framework/sessions storage/framework/views storage/framework/cache
+mkdir -p bootstrap/cache public/build
+chmod -R 775 storage bootstrap/cache
+info "Created storage directories"
+
+# Generate SSL self-signed cert for HTTPS
+mkdir -p docker/nginx/ssl
+if [ ! -f docker/nginx/ssl/cert.pem ]; then
+ info "Generating self-signed SSL certificate..."
+ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
+ -keyout docker/nginx/ssl/key.pem \
+ -out docker/nginx/ssl/cert.pem \
+ -subj "/C=US/ST=Local/L=Local/O=InfraManager/CN=localhost" 2>/dev/null
+ success "SSL certificate generated"
+fi
+
+# Build and start containers
+info "Building Docker containers (this may take a few minutes)..."
+docker compose build --no-cache php
+
+info "Starting services..."
+docker compose up -d nginx redis
+
+info "Waiting for services to be healthy..."
+sleep 5
+
+# Run Laravel setup inside container
+info "Installing PHP dependencies..."
+docker compose exec php composer install --optimize-autoloader --no-interaction
+
+info "Generating application key..."
+docker compose exec php php artisan key:generate --force
+
+info "Running database migrations..."
+docker compose exec php php artisan migrate --force
+
+info "Seeding database (default admin user)..."
+docker compose exec php php artisan db:seed --force
+
+info "Creating storage link..."
+docker compose exec php php artisan storage:link
+
+info "Caching routes and config..."
+docker compose exec php php artisan config:cache
+docker compose exec php php artisan route:cache
+
+success "Setup complete!"
+echo ""
+echo -e "${GREEN}╔════════════════════════════════════════════════╗${NC}"
+echo -e "${GREEN}║ Infra Manager is ready! ║${NC}"
+echo -e "${GREEN}║ ║${NC}"
+echo -e "${GREEN}║ URL: http://localhost:8080 ║${NC}"
+echo -e "${GREEN}║ HTTPS: https://localhost:8443 ║${NC}"
+echo -e "${GREEN}║ Email: admin@infra.local ║${NC}"
+echo -e "${GREEN}║ Password: Admin@123456 ║${NC}"
+echo -e "${GREEN}║ ║${NC}"
+echo -e "${GREEN}║ Run dev mode (with HMR): ║${NC}"
+echo -e "${GREEN}║ docker compose --profile dev up ║${NC}"
+echo -e "${GREEN}╚════════════════════════════════════════════════╝${NC}"
+echo ""
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..f0851bc
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,149 @@
+const { fontFamily } = require("tailwindcss/defaultTheme");
+
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ darkMode: ["class"],
+ content: [
+ "./resources/js/**/*.{js,jsx,ts,tsx}",
+ "./resources/views/**/*.blade.php",
+ ],
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ // Custom infra-manager colors
+ cyber: {
+ 50: "#f0fdf9",
+ 100: "#ccfbef",
+ 200: "#99f6df",
+ 300: "#5eead4",
+ 400: "#2dd4bf",
+ 500: "#14b8a6",
+ 600: "#0d9488",
+ 700: "#0f766e",
+ 800: "#115e59",
+ 900: "#134e4a",
+ 950: "#042f2e",
+ },
+ neon: {
+ blue: "#00d4ff",
+ green: "#00ff88",
+ purple: "#9d4edd",
+ orange: "#ff6b35",
+ red: "#ff2d55",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ fontFamily: {
+ sans: ["Inter", ...fontFamily.sans],
+ mono: ["JetBrains Mono", ...fontFamily.mono],
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: 0 },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: 0 },
+ },
+ shimmer: {
+ from: { backgroundPosition: "0 0" },
+ to: { backgroundPosition: "-200% 0" },
+ },
+ "border-beam": {
+ "100%": { "offset-distance": "100%" },
+ },
+ aurora: {
+ from: { backgroundPosition: "50% 50%, 50% 50%" },
+ to: { backgroundPosition: "350% 50%, 350% 50%" },
+ },
+ meteor: {
+ "0%": { transform: "rotate(215deg) translateX(0)", opacity: 1 },
+ "70%": { opacity: 1 },
+ "100%": {
+ transform: "rotate(215deg) translateX(-500px)",
+ opacity: 0,
+ },
+ },
+ grid: {
+ "0%": { transform: "translateY(-50%)" },
+ "100%": { transform: "translateY(0)" },
+ },
+ pulse: {
+ "0%, 100%": { boxShadow: "0 0 0 0 var(--pulse-color)" },
+ "50%": { boxShadow: "0 0 0 8px var(--pulse-color)" },
+ },
+ "spin-around": {
+ "0%": { transform: "translateZ(0) rotate(0)" },
+ "15%, 35%": { transform: "translateZ(0) rotate(90deg)" },
+ "65%, 85%": { transform: "translateZ(0) rotate(270deg)" },
+ "100%": { transform: "translateZ(0) rotate(360deg)" },
+ },
+ slide: {
+ to: { transform: "translate(calc(100cqw - 100%), 0)" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ shimmer: "shimmer 2s linear infinite",
+ "border-beam": "border-beam calc(var(--duration)*1s) infinite linear",
+ aurora: "aurora 60s linear infinite",
+ meteor: "meteor 5s linear infinite",
+ grid: "grid 15s linear infinite",
+ pulse: "pulse var(--duration) ease-out infinite",
+ "spin-around": "spin-around calc(var(--speed) * 2) infinite linear",
+ slide: "slide var(--speed) ease-in-out infinite alternate",
+ },
+ },
+ },
+ plugins: [
+ require("tailwindcss-animate"),
+ require("@tailwindcss/forms"),
+ require("@tailwindcss/typography"),
+ ],
+};
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..f680c5c
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,29 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+
+export default defineConfig({
+ plugins: [
+ react(),
+ ],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'resources/js'),
+ '~': resolve(__dirname, 'resources'),
+ },
+ },
+ build: {
+ outDir: 'public/build',
+ manifest: true,
+ rollupOptions: {
+ input: 'resources/js/app.jsx',
+ },
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ hmr: {
+ host: 'localhost',
+ },
+ },
+});
+ >
+ );
+}
diff --git a/resources/js/Pages/Hypervisor/Create.jsx b/resources/js/Pages/Hypervisor/Create.jsx
new file mode 100644
index 0000000..87bbcd0
--- /dev/null
+++ b/resources/js/Pages/Hypervisor/Create.jsx
@@ -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 (
+
+
+ );
+}
diff --git a/resources/js/Pages/Hypervisor/Index.jsx b/resources/js/Pages/Hypervisor/Index.jsx
new file mode 100644
index 0000000..897a240
--- /dev/null
+++ b/resources/js/Pages/Hypervisor/Index.jsx
@@ -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 => (
+
+ )},
+ { header: 'Host', accessor: 'host', render: row => (
+ {row.host}:{row.port}
+ )},
+ { header: 'VMs', key: 'vms', render: row => (
+ {row.virtual_machines_count ?? 0}
+ )},
+ { header: 'Status', accessor: 'status', render: row => },
+ { header: 'Last Sync', accessor: 'last_sync_at', render: row => (
+ {timeAgo(row.last_sync_at)}
+ )},
+ { header: '', key: 'actions', render: row => (
+
+
+ );
+}
diff --git a/resources/js/Pages/Identity/Index.jsx b/resources/js/Pages/Identity/Index.jsx
new file mode 100644
index 0000000..958dd9a
--- /dev/null
+++ b/resources/js/Pages/Identity/Index.jsx
@@ -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 => (
+
+ )},
+ { header: 'Username', accessor: 'username', render: row => (
+ {row.username ?? '—'}
+ )},
+ { header: 'Category', accessor: 'category', render: row => (
+ row.category ? {row.category} : null
+ )},
+ { header: 'Expires', accessor: 'expires_at', render: row => (
+ row.expires_at
+ ? {timeAgo(row.expires_at)}
+ : Never
+ )},
+ { header: '', key: 'actions', render: row => (
+
+ )},
+ ];
+
+ const certColumns = [
+ { header: 'Domain', accessor: 'domain', render: row => (
+
+ )},
+ { header: 'Type', accessor: 'type', render: row => (
+ {row.type?.replace('-', ' ')}
+ )},
+ { header: 'Expires', accessor: 'expires_at', render: row => {
+ const daysLeft = Math.ceil((new Date(row.expires_at) - new Date()) / 86400000);
+ return (
+
+ );
+ }},
+ { header: 'Status', accessor: 'status', render: row => },
+ { header: '', key: 'actions', render: row => (
+ Download
+ )},
+ ];
+
+ return (
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Monitoring/Index.jsx b/resources/js/Pages/Monitoring/Index.jsx
new file mode 100644
index 0000000..1582527
--- /dev/null
+++ b/resources/js/Pages/Monitoring/Index.jsx
@@ -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 => (
+
+ )},
+ { header: 'Severity', accessor: 'severity', render: row => (
+
+ )},
+ { header: 'Status', accessor: 'status', render: row => (
+
+ )},
+ { header: 'Fired', accessor: 'created_at', render: row => (
+ {timeAgo(row.created_at)}
+ )},
+ { header: '', key: 'actions', render: row => (
+ row.status === 'firing' && (
+ {}}>Acknowledge
+ )
+ )},
+ ];
+
+ return (
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Security/Index.jsx b/resources/js/Pages/Security/Index.jsx
new file mode 100644
index 0000000..d6c94b8
--- /dev/null
+++ b/resources/js/Pages/Security/Index.jsx
@@ -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 (
+
+ );
+}
+
+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 => (
+
+ )},
+ { header: 'Score', accessor: 'score', render: row => (
+
+ )},
+ { header: 'Findings', key: 'findings', render: row => (
+
+
+ );
+}
diff --git a/resources/js/Pages/Wiki/Index.jsx b/resources/js/Pages/Wiki/Index.jsx
new file mode 100644
index 0000000..805dea5
--- /dev/null
+++ b/resources/js/Pages/Wiki/Index.jsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/resources/js/app.jsx b/resources/js/app.jsx
new file mode 100644
index 0000000..7585311
--- /dev/null
+++ b/resources/js/app.jsx
@@ -0,0 +1,24 @@
+import './bootstrap';
+import '../css/app.css';
+
+import { createRoot } from 'react-dom/client';
+import { createInertiaApp } from '@inertiajs/react';
+import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
+
+createInertiaApp({
+ title: (title) => `${title} — Infra Manager`,
+ resolve: (name) =>
+ resolvePageComponent(
+ `./Pages/${name}.jsx`,
+ import.meta.glob('./Pages/**/*.jsx')
+ ),
+ setup({ el, App, props }) {
+ const root = createRoot(el);
+ root.render();
+ },
+ progress: {
+ color: '#00d4ff',
+ includeCSS: true,
+ showSpinner: true,
+ },
+});
diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js
new file mode 100644
index 0000000..7d62edc
--- /dev/null
+++ b/resources/js/bootstrap.js
@@ -0,0 +1,4 @@
+import axios from 'axios';
+
+window.axios = axios;
+window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
diff --git a/resources/js/lib/utils.js b/resources/js/lib/utils.js
new file mode 100644
index 0000000..1ab0933
--- /dev/null
+++ b/resources/js/lib/utils.js
@@ -0,0 +1,71 @@
+import { clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatBytes(bytes, decimals = 2) {
+ if (!bytes || bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+}
+
+export function formatUptime(seconds) {
+ const d = Math.floor(seconds / 86400);
+ const h = Math.floor((seconds % 86400) / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
+export function getStatusColor(status) {
+ const map = {
+ online: 'text-green-400',
+ running: 'text-cyan-400',
+ active: 'text-green-400',
+ stopped: 'text-slate-400',
+ offline: 'text-red-400',
+ error: 'text-red-400',
+ warning: 'text-yellow-400',
+ unknown: 'text-gray-400',
+ paused: 'text-yellow-400',
+ exited: 'text-slate-400',
+ };
+ return map[status?.toLowerCase()] ?? 'text-gray-400';
+}
+
+export function getStatusDot(status) {
+ const map = {
+ online: 'bg-green-400',
+ running: 'bg-cyan-400',
+ active: 'bg-green-400',
+ stopped: 'bg-slate-500',
+ offline: 'bg-red-400',
+ error: 'bg-red-400',
+ warning: 'bg-yellow-400',
+ unknown: 'bg-gray-500',
+ paused: 'bg-yellow-400',
+ exited: 'bg-slate-500',
+ };
+ return map[status?.toLowerCase()] ?? 'bg-gray-500';
+}
+
+export function getSecurityGradeColor(grade) {
+ const map = { A: 'text-green-400', B: 'text-cyan-400', C: 'text-yellow-400', D: 'text-orange-400', F: 'text-red-400' };
+ return map[grade] ?? 'text-gray-400';
+}
+
+export function timeAgo(date) {
+ if (!date) return 'Never';
+ const d = new Date(date);
+ const seconds = Math.floor((new Date() - d) / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+}
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
new file mode 100644
index 0000000..c173b15
--- /dev/null
+++ b/resources/views/app.blade.php
@@ -0,0 +1,24 @@
+
+
+