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

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

69
.env.example Normal file
View File

@@ -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}"

20
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Acl;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
public function index()
{
return Inertia::render('Acl/Index', [
'users' => \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.');
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Acl;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function index()
{
return redirect()->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.');
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;
class AdminController extends Controller
{
public function index()
{
return Inertia::render('Admin/Index', [
'stats' => [
'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];
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\LdapConfig;
use Illuminate\Http\Request;
use Inertia\Inertia;
class LdapController extends Controller
{
public function index()
{
return Inertia::render('Admin/Ldap', [
'config' => 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.');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SettingsController extends Controller
{
public function index()
{
$settings = Setting::all()->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.');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
class MetricsController extends Controller
{
public function live(string $target)
{
// Return real-time metric stream
return response()->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(),
]);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\VirtualMachine;
class WebSocketController extends Controller
{
public function vmConsole(VirtualMachine $vm)
{
// Return WebSocket ticket for VM console
return response()->json(['ticket' => base64_encode(random_bytes(32))]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class AuthController extends Controller
{
public function showLogin(): Response
{
return Inertia::render('Auth/Login', [
'ldapEnabled' => 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');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Backup;
use App\Http\Controllers\Controller;
use App\Models\Backup;
use App\Models\BackupPolicy;
use Illuminate\Http\Request;
use Inertia\Inertia;
class BackupController extends Controller
{
public function index()
{
return Inertia::render('Backup/Index', [
'backups' => 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.');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Backup;
use App\Http\Controllers\Controller;
use App\Models\Backup;
use Illuminate\Http\Request;
class RestoreController extends Controller
{
public function restore(Request $request, Backup $backup)
{
$data = $request->validate([
'restore_mode' => 'required|in:overwrite,new,test',
]);
// Dispatch restore job
return back()->with('success', 'Restore initiated.');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Cloud;
use App\Http\Controllers\Controller;
use App\Models\CloudAccount;
use App\Models\CloudResource;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CloudAccountController extends Controller
{
public function index()
{
return Inertia::render('Cloud/Index', [
'accounts' => 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.');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Cloud;
use App\Http\Controllers\Controller;
use App\Models\CloudResource;
use Inertia\Inertia;
class CloudResourceController extends Controller
{
public function index()
{
return Inertia::render('Cloud/Resources', [
'resources' => CloudResource::with('account')->paginate(50),
]);
}
public function show(CloudResource $resource)
{
return Inertia::render('Cloud/ResourceShow', ['resource' => $resource->load('account')]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Container;
use App\Http\Controllers\Controller;
use App\Models\Container;
use App\Models\DockerEndpoint;
use App\Services\Container\DockerManager;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ContainerController extends Controller
{
public function __construct(private DockerManager $docker) {}
public function index()
{
return Inertia::render('Container/Index', [
'containers' => 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.');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Container;
use App\Http\Controllers\Controller;
use App\Models\DockerEndpoint;
use App\Models\Stack;
use App\Services\Container\DockerManager;
use Illuminate\Http\Request;
use Inertia\Inertia;
class StackController extends Controller
{
public function __construct(private DockerManager $docker) {}
public function index()
{
return Inertia::render('Container/Stacks', [
'stacks' => 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.');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers;
use App\Models\Alert;
use App\Models\CloudAccount;
use App\Models\Container;
use App\Models\DockerEndpoint;
use App\Models\Hypervisor;
use App\Models\SecurityScore;
use App\Models\VirtualMachine;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function index(): Response
{
$hypervisors = Hypervisor::select('id', 'name', 'type', 'status', 'last_sync_at')->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,
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Hypervisor;
use App\Http\Controllers\Controller;
use App\Models\Hypervisor;
use App\Services\Hypervisor\HypervisorManager;
use Illuminate\Http\Request;
use Inertia\Inertia;
class HypervisorController extends Controller
{
public function __construct(private HypervisorManager $manager) {}
public function index()
{
$hypervisors = Hypervisor::withCount('virtualMachines')
->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);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Hypervisor;
use App\Http\Controllers\Controller;
use App\Models\Hypervisor;
use App\Models\VmTemplate;
use Illuminate\Http\Request;
use Inertia\Inertia;
class TemplateController extends Controller
{
public function index()
{
return Inertia::render('Hypervisor/Templates', [
'templates' => 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.');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Hypervisor;
use App\Http\Controllers\Controller;
use App\Models\VirtualMachine;
use App\Services\Hypervisor\HypervisorManager;
use Illuminate\Http\Request;
use Inertia\Inertia;
class VirtualMachineController extends Controller
{
public function __construct(private HypervisorManager $manager) {}
public function index()
{
return Inertia::render('Hypervisor/VMs', [
'vms' => 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.');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Identity;
use App\Http\Controllers\Controller;
use App\Models\Certificate;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CertificateController extends Controller
{
public function index()
{
return Inertia::render('Identity/Certificates', [
'certificates' => 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.');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Identity;
use App\Http\Controllers\Controller;
use App\Models\PasswordEntry;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PasswordController extends Controller
{
public function index()
{
return Inertia::render('Identity/Index', [
'passwords' => 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.');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Monitoring;
use App\Http\Controllers\Controller;
use App\Models\Alert;
use App\Models\AlertRule;
use App\Models\Metric;
use Inertia\Inertia;
class MonitoringController extends Controller
{
public function index()
{
return Inertia::render('Monitoring/Index', [
'alerts' => 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');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Security;
use App\Http\Controllers\Controller;
use App\Models\SecurityScan;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ScanController extends Controller
{
public function index()
{
return Inertia::render('Security/Scans', [
'scans' => 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]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Security;
use App\Http\Controllers\Controller;
use App\Models\SecurityScan;
use App\Models\SecurityScore;
use Inertia\Inertia;
class SecurityDashboardController extends Controller
{
public function index()
{
$scans = SecurityScan::orderByDesc('created_at')->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');
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Wiki;
use App\Http\Controllers\Controller;
use App\Models\WikiPage;
use App\Models\WikiSpace;
use Illuminate\Http\Request;
use Inertia\Inertia;
use League\CommonMark\CommonMarkConverter;
class DocumentController extends Controller
{
private CommonMarkConverter $markdown;
public function __construct()
{
$this->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.');
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Wiki;
use App\Http\Controllers\Controller;
use App\Models\WikiPage;
use App\Models\WikiSpace;
use Illuminate\Http\Request;
use Inertia\Inertia;
class WikiController extends Controller
{
public function index()
{
return Inertia::render('Wiki/Index', [
'spaces' => 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.');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Middleware;
use App\Models\Setting;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function version(Request $request): ?string
{
return parent::version($request);
}
public function share(Request $request): array
{
$user = $request->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;
}
}

22
app/Models/Alert.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Alert extends Model
{
protected $fillable = [
'rule_id', 'target_type', 'target_id', 'target_name',
'title', 'message', 'severity', 'status',
'acknowledged_by', 'acknowledged_at', 'resolved_at',
];
protected $casts = [
'acknowledged_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function rule() { return $this->belongsTo(AlertRule::class); }
public function acknowledgedBy() { return $this->belongsTo(User::class, 'acknowledged_by'); }
}

14
app/Models/AlertRule.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AlertRule extends Model
{
protected $fillable = [
'name', 'target_type', 'metric_name', 'condition', 'threshold',
'duration_seconds', 'severity', 'notification_channels', 'is_active', 'created_by',
];
protected $casts = ['notification_channels' => 'array', 'is_active' => 'boolean'];
}

16
app/Models/Backup.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Backup extends Model
{
protected $fillable = [
'policy_id', 'target_type', 'target_id', 'target_name',
'status', 'storage_path', 'size_bytes', 'checksum', 'metadata', 'notes',
'expires_at', 'created_by', 'started_at', 'completed_at',
];
protected $casts = ['metadata' => 'array', 'expires_at' => 'datetime', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
public function policy() { return $this->belongsTo(BackupPolicy::class); }
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BackupPolicy extends Model
{
protected $fillable = [
'name', 'target_type', 'target_ids', 'schedule', 'retention_type', 'retention_value',
'storage_type', 'storage_config', 'is_active', 'compression', 'encryption', 'created_by',
];
protected $casts = ['target_ids' => 'array', 'storage_config' => 'array', 'compression' => 'array', 'encryption' => 'array', 'is_active' => 'boolean'];
public function backups() { return $this->hasMany(Backup::class, 'policy_id'); }
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Crypt;
class Certificate extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'domain', 'san', 'certificate', 'private_key', 'chain',
'issuer', 'type', 'auto_renew', 'issued_at', 'expires_at', 'tags', 'status', 'created_by',
];
protected $hidden = ['certificate', 'private_key', 'chain'];
protected $casts = [
'san' => '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'); }
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Crypt;
class CloudAccount extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'provider', 'account_id', 'credentials', 'region',
'regions', 'is_active', 'status', 'tags', 'last_sync_at', 'created_by',
];
protected $hidden = ['credentials'];
protected $casts = [
'is_active' => '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'); }
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CloudResource extends Model
{
protected $fillable = [
'account_id', 'resource_id', 'resource_type', 'name',
'status', 'region', 'properties', 'tags', 'monthly_cost',
];
protected $casts = ['properties' => 'array', 'tags' => 'array'];
public function account() { return $this->belongsTo(CloudAccount::class); }
}

23
app/Models/Container.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Container extends Model
{
protected $fillable = [
'endpoint_id', 'container_id', 'name', 'image', 'image_id',
'status', 'state', 'ports', 'volumes', 'env', 'labels', 'networks', 'created_at_unix',
];
protected $casts = [
'ports' => 'array',
'volumes' => 'array',
'env' => 'array',
'labels' => 'array',
'networks' => 'array',
];
public function endpoint() { return $this->belongsTo(DockerEndpoint::class); }
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class DockerEndpoint extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'type', 'host', 'tls_enabled', 'tls_cert', 'tls_key', 'tls_ca',
'status', 'info', 'last_sync_at', 'created_by',
];
protected $hidden = ['tls_cert', 'tls_key', 'tls_ca'];
protected $casts = [
'tls_enabled' => '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'); }
}

70
app/Models/Hypervisor.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Crypt;
class Hypervisor extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'type', 'version', 'host', 'port',
'username', 'password', 'ssl_verify', 'datacenter',
'status', 'metadata', 'last_sync_at', 'last_error', 'created_by',
];
protected $casts = [
'ssl_verify' => '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),
};
}
}

35
app/Models/LdapConfig.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class LdapConfig extends Model
{
protected $fillable = [
'name', 'host', 'port', 'use_ssl', 'use_tls', 'base_dn',
'bind_dn', 'bind_password', 'user_search_base', 'user_filter',
'group_search_base', 'group_filter', 'admin_group_dn',
'attribute_map', 'is_active', 'status',
];
protected $hidden = ['bind_password'];
protected $casts = [
'use_ssl' => '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;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Crypt;
class PasswordEntry extends Model
{
use SoftDeletes;
protected $fillable = [
'user_id', 'title', 'category', 'url', 'username',
'password', 'notes', 'custom_fields', 'tags',
'is_shared', 'shared_with', 'expires_at', 'last_used_at',
];
protected $hidden = ['password', 'notes', 'custom_fields'];
protected $casts = [
'tags' => '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); }
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SecurityScan extends Model
{
protected $fillable = [
'target_type', 'target_id', 'target_name', 'scan_type', 'status',
'score', 'grade', 'critical_count', 'high_count', 'medium_count', 'low_count', 'info_count',
'findings', 'raw_output', 'initiated_by', 'started_at', 'completed_at',
];
protected $casts = [
'findings' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SecurityScore extends Model
{
protected $fillable = ['target_type', 'target_id', 'score', 'grade', 'breakdown', 'calculated_at'];
protected $casts = ['breakdown' => 'array', 'calculated_at' => 'datetime'];
}

57
app/Models/Setting.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class Setting extends Model
{
protected $fillable = ['group', 'key', 'value', 'type', 'is_encrypted'];
protected $casts = [
'is_encrypted' => '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,
]);
}
}

15
app/Models/Stack.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Stack extends Model
{
use SoftDeletes;
protected $fillable = ['endpoint_id', 'name', 'type', 'compose_content', 'env_vars', 'status', 'remote_id', 'created_by'];
protected $casts = ['env_vars' => 'array'];
public function endpoint() { return $this->belongsTo(DockerEndpoint::class); }
public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
}

58
app/Models/User.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Activitylog\Traits\CausesActivity;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, CausesActivity;
protected $fillable = [
'name', 'email', 'password', 'avatar', 'auth_source',
'ldap_guid', 'ldap_domain', 'is_active', 'mfa_enabled',
'mfa_secret', 'mfa_recovery_codes', 'last_login_at',
'last_login_ip', 'preferences',
];
protected $hidden = [
'password', 'remember_token', 'mfa_secret', 'mfa_recovery_codes',
];
protected $casts = [
'email_verified_at' => '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);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VirtualMachine extends Model
{
protected $fillable = [
'hypervisor_id', 'remote_id', 'name', 'status', 'os_type', 'os_version',
'cpu_count', 'memory_mb', 'disk_gb', 'ip_address', 'mac_address',
'host_node', 'cluster', 'datastore', 'tags', 'metadata',
'cpu_usage', 'memory_usage', 'created_by',
];
protected $casts = [
'tags' => 'array',
'metadata' => 'array',
];
public function hypervisor() { return $this->belongsTo(Hypervisor::class); }
public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
}

19
app/Models/VmTemplate.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class VmTemplate extends Model
{
use SoftDeletes;
protected $fillable = [
'hypervisor_id', 'name', 'description', 'os_type', 'os_version',
'cpu_count', 'memory_mb', 'disk_gb', 'network_config', 'customization',
'variables', 'provision_script', 'status', 'remote_id', 'tags', 'created_by',
];
protected $casts = ['network_config' => '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'); }
}

25
app/Models/WikiPage.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class WikiPage extends Model
{
use SoftDeletes;
protected $fillable = [
'space_id', 'parent_id', 'title', 'slug', 'content', 'content_html',
'tags', 'is_published', 'order', 'created_by', 'updated_by',
];
protected $casts = ['tags' => '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'); }
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WikiPageRevision extends Model
{
protected $fillable = ['page_id', 'content', 'change_summary', 'created_by'];
public function page() { return $this->belongsTo(WikiPage::class); }
public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
}

18
app/Models/WikiSpace.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class WikiSpace extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name', 'slug', 'description', 'icon', 'color', 'is_public', 'created_by'];
protected $casts = ['is_public' => 'boolean'];
public function pages() { return $this->hasMany(WikiPage::class, 'space_id'); }
public function createdBy() { return $this->belongsTo(User::class, 'created_by'); }
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Bind services
$this->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;
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [];
public function boot(): void
{
// Register LDAP auth guard if LDAP is enabled
if (config('ldap.connections.default.hosts') && config('ldap.auth.enabled', false)) {
Auth::provider('ldap', function ($app, array $config) {
return new \LdapRecord\Laravel\Auth\UserProvider(
$app['hash'],
$config
);
});
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Services\Container;
use App\Models\Container;
use App\Models\DockerEndpoint;
use GuzzleHttp\Client;
class DockerManager
{
private array $clients = [];
private function client(DockerEndpoint $endpoint): Client
{
$key = $endpoint->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;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Services\Hypervisor;
use App\Models\Hypervisor;
class HyperVDriver implements HypervisorDriverInterface
{
public function __construct(private Hypervisor $hypervisor) {}
public function testConnection(): array
{
// WinRM / PowerShell remoting
return ['status' => '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 []; }
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Services\Hypervisor;
interface HypervisorDriverInterface
{
public function testConnection(): array;
public function sync(): void;
public function getVMs(): array;
public function getVM(string $id): array;
public function powerOn(string $vmId): bool;
public function powerOff(string $vmId): bool;
public function reboot(string $vmId): bool;
public function suspend(string $vmId): bool;
public function createSnapshot(string $vmId, string $name, string $description = ''): bool;
public function cloneVM(string $vmId, string $name, array $config): array;
public function getMetrics(string $vmId): array;
public function deployTemplate(array $templateConfig, array $deployConfig): array;
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Services\Hypervisor;
use App\Models\Hypervisor;
class HypervisorManager
{
private array $drivers = [];
public function driver(Hypervisor $hypervisor): HypervisorDriverInterface
{
$key = $hypervisor->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()]);
}
});
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Services\Hypervisor;
use App\Models\Hypervisor;
use App\Models\VirtualMachine;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
class ProxmoxDriver implements HypervisorDriverInterface
{
private Client $client;
private string $ticket = '';
private string $csrfToken = '';
public function __construct(private Hypervisor $hypervisor)
{
$this->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);
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Services\Hypervisor;
use App\Models\Hypervisor;
use App\Models\VirtualMachine;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
class VCenterDriver implements HypervisorDriverInterface
{
private Client $client;
private string $sessionId = '';
public function __construct(private Hypervisor $hypervisor)
{
$this->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) ?? [];
}
}

41
bootstrap/app.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\EnsurePermissions;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Inertia\Inertia;
return Application::configure(basePath: dirname(__DIR__))
->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();

8
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
Spatie\Permission\PermissionServiceProvider::class,
LdapRecord\Laravel\LdapAuthServiceProvider::class,
];

77
composer.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "infra/manager",
"description": "All-in-One System Infrastructure Manager",
"keywords": ["infra", "manager", "laravel", "devops"],
"license": "MIT",
"require": {
"php": "^8.3",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"fruitcake/laravel-cors": "^3.0",
"guzzlehttp/guzzle": "^7.9",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^11.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9",
"league/flysystem-local": "^3.0",
"spatie/laravel-activitylog": "^4.9",
"spatie/laravel-permission": "^6.9",
"directorytree/ldaprecord-laravel": "^3.2",
"league/commonmark": "^2.5",
"phpseclib/phpseclib": "^3.0",
"tightenco/ziggy": "^2.3"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php artisan migrate --ansi",
"@php artisan db:seed --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

12
config/activitylog.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
return [
'enabled' => 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'),
];

45
config/app.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
return [
'name' => 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(),
];

20
config/auth.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
return [
'defaults' => ['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,
];

11
config/cache.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return [
'default' => 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_'),
];

47
config/database.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
return [
'default' => 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')],
],
];

36
config/ldap.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
return [
'default' => 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),
],
];

33
config/permission.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
return [
'models' => [
'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',
],
];

12
config/queue.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
return [
'default' => 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'],
];

20
config/session.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
return [
'driver' => 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),
];

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,102 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('hypervisors', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('docker_endpoints', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cloud_accounts', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('security_scans', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('backup_policies', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('metrics', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('password_entries', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wiki_spaces', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ldap_configs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,96 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Create roles
$superAdmin = Role::firstOrCreate(['name' => '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',
]);
}
}

119
docker-compose.yml Normal file
View File

@@ -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

97
docker/nginx/default.conf Normal file
View File

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

92
docker/php/Dockerfile Normal file
View File

@@ -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"]

8
docker/php/opcache.ini Normal file
View File

@@ -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

7
docker/php/php.ini Normal file
View File

@@ -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

View File

@@ -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

50
package.json Normal file
View File

@@ -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"
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More