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:
69
.env.example
Normal file
69
.env.example
Normal 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
20
.gitignore
vendored
Normal 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
|
||||
58
app/Http/Controllers/Acl/RoleController.php
Normal file
58
app/Http/Controllers/Acl/RoleController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/Acl/UserController.php
Normal file
93
app/Http/Controllers/Acl/UserController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
108
app/Http/Controllers/Admin/AdminController.php
Normal file
108
app/Http/Controllers/Admin/AdminController.php
Normal 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];
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Admin/LdapController.php
Normal file
83
app/Http/Controllers/Admin/LdapController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Admin/SettingsController.php
Normal file
27
app/Http/Controllers/Admin/SettingsController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/Api/MetricsController.php
Normal file
21
app/Http/Controllers/Api/MetricsController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
15
app/Http/Controllers/Api/WebSocketController.php
Normal file
15
app/Http/Controllers/Api/WebSocketController.php
Normal 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))]);
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Auth/AuthController.php
Normal file
83
app/Http/Controllers/Auth/AuthController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Backup/BackupController.php
Normal file
52
app/Http/Controllers/Backup/BackupController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
19
app/Http/Controllers/Backup/RestoreController.php
Normal file
19
app/Http/Controllers/Backup/RestoreController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Cloud/CloudAccountController.php
Normal file
69
app/Http/Controllers/Cloud/CloudAccountController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
22
app/Http/Controllers/Cloud/CloudResourceController.php
Normal file
22
app/Http/Controllers/Cloud/CloudResourceController.php
Normal 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')]);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Container/ContainerController.php
Normal file
54
app/Http/Controllers/Container/ContainerController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/Container/StackController.php
Normal file
68
app/Http/Controllers/Container/StackController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/DashboardController.php
Normal file
68
app/Http/Controllers/DashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
121
app/Http/Controllers/Hypervisor/HypervisorController.php
Normal file
121
app/Http/Controllers/Hypervisor/HypervisorController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
77
app/Http/Controllers/Hypervisor/TemplateController.php
Normal file
77
app/Http/Controllers/Hypervisor/TemplateController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Hypervisor/VirtualMachineController.php
Normal file
60
app/Http/Controllers/Hypervisor/VirtualMachineController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Identity/CertificateController.php
Normal file
69
app/Http/Controllers/Identity/CertificateController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Identity/PasswordController.php
Normal file
60
app/Http/Controllers/Identity/PasswordController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Monitoring/MonitoringController.php
Normal file
58
app/Http/Controllers/Monitoring/MonitoringController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Security/ScanController.php
Normal file
44
app/Http/Controllers/Security/ScanController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Wiki/DocumentController.php
Normal file
96
app/Http/Controllers/Wiki/DocumentController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Wiki/WikiController.php
Normal file
54
app/Http/Controllers/Wiki/WikiController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
74
app/Http/Middleware/HandleInertiaRequests.php
Normal file
74
app/Http/Middleware/HandleInertiaRequests.php
Normal 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
22
app/Models/Alert.php
Normal 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
14
app/Models/AlertRule.php
Normal 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
16
app/Models/Backup.php
Normal 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); }
|
||||
}
|
||||
15
app/Models/BackupPolicy.php
Normal file
15
app/Models/BackupPolicy.php
Normal 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'); }
|
||||
}
|
||||
34
app/Models/Certificate.php
Normal file
34
app/Models/Certificate.php
Normal 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'); }
|
||||
}
|
||||
40
app/Models/CloudAccount.php
Normal file
40
app/Models/CloudAccount.php
Normal 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'); }
|
||||
}
|
||||
15
app/Models/CloudResource.php
Normal file
15
app/Models/CloudResource.php
Normal 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
23
app/Models/Container.php
Normal 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); }
|
||||
}
|
||||
29
app/Models/DockerEndpoint.php
Normal file
29
app/Models/DockerEndpoint.php
Normal 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
70
app/Models/Hypervisor.php
Normal 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
35
app/Models/LdapConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Models/PasswordEntry.php
Normal file
40
app/Models/PasswordEntry.php
Normal 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); }
|
||||
}
|
||||
19
app/Models/SecurityScan.php
Normal file
19
app/Models/SecurityScan.php
Normal 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',
|
||||
];
|
||||
}
|
||||
11
app/Models/SecurityScore.php
Normal file
11
app/Models/SecurityScore.php
Normal 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
57
app/Models/Setting.php
Normal 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
15
app/Models/Stack.php
Normal 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
58
app/Models/User.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Models/VirtualMachine.php
Normal file
23
app/Models/VirtualMachine.php
Normal 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
19
app/Models/VmTemplate.php
Normal 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
25
app/Models/WikiPage.php
Normal 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'); }
|
||||
}
|
||||
12
app/Models/WikiPageRevision.php
Normal file
12
app/Models/WikiPageRevision.php
Normal 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
18
app/Models/WikiSpace.php
Normal 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'); }
|
||||
}
|
||||
42
app/Providers/AppServiceProvider.php
Normal file
42
app/Providers/AppServiceProvider.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
25
app/Providers/AuthServiceProvider.php
Normal file
25
app/Providers/AuthServiceProvider.php
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Services/Container/DockerManager.php
Normal file
114
app/Services/Container/DockerManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
app/Services/Hypervisor/HyperVDriver.php
Normal file
32
app/Services/Hypervisor/HyperVDriver.php
Normal 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 []; }
|
||||
}
|
||||
19
app/Services/Hypervisor/HypervisorDriverInterface.php
Normal file
19
app/Services/Hypervisor/HypervisorDriverInterface.php
Normal 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;
|
||||
}
|
||||
53
app/Services/Hypervisor/HypervisorManager.php
Normal file
53
app/Services/Hypervisor/HypervisorManager.php
Normal 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()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
179
app/Services/Hypervisor/ProxmoxDriver.php
Normal file
179
app/Services/Hypervisor/ProxmoxDriver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
163
app/Services/Hypervisor/VCenterDriver.php
Normal file
163
app/Services/Hypervisor/VCenterDriver.php
Normal 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
41
bootstrap/app.php
Normal 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
8
bootstrap/providers.php
Normal 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
77
composer.json
Normal 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
12
config/activitylog.php
Normal 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
45
config/app.php
Normal 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
20
config/auth.php
Normal 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
11
config/cache.php
Normal 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
47
config/database.php
Normal 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
36
config/ldap.php
Normal 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
33
config/permission.php
Normal 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
12
config/queue.php
Normal 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
20
config/session.php
Normal 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),
|
||||
];
|
||||
55
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
55
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
29
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
29
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
51
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
51
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
50
database/migrations/2024_01_01_000004_create_cloud_table.php
Normal file
50
database/migrations/2024_01_01_000004_create_cloud_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
73
database/migrations/2024_01_01_000009_create_wiki_table.php
Normal file
73
database/migrations/2024_01_01_000009_create_wiki_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
37
database/migrations/2024_01_01_000010_create_ldap_table.php
Normal file
37
database/migrations/2024_01_01_000010_create_ldap_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
96
database/seeders/DatabaseSeeder.php
Normal file
96
database/seeders/DatabaseSeeder.php
Normal 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
119
docker-compose.yml
Normal 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
97
docker/nginx/default.conf
Normal 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
92
docker/php/Dockerfile
Normal 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
8
docker/php/opcache.ini
Normal 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
7
docker/php/php.ini
Normal 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
|
||||
32
docker/supervisor/supervisord.conf
Normal file
32
docker/supervisor/supervisord.conf
Normal 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
50
package.json
Normal 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
112
resources/css/app.css
Normal 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%; }
|
||||
}
|
||||
42
resources/js/Components/ui/Button.jsx
Normal file
42
resources/js/Components/ui/Button.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
resources/js/Components/ui/DataTable.jsx
Normal file
137
resources/js/Components/ui/DataTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
resources/js/Components/ui/GlassCard.jsx
Normal file
59
resources/js/Components/ui/GlassCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
resources/js/Components/ui/Modal.jsx
Normal file
54
resources/js/Components/ui/Modal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
resources/js/Components/ui/ProgressBar.jsx
Normal file
40
resources/js/Components/ui/ProgressBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
resources/js/Components/ui/StatusBadge.jsx
Normal file
34
resources/js/Components/ui/StatusBadge.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
resources/js/Layouts/AppLayout.jsx
Normal file
253
resources/js/Layouts/AppLayout.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
resources/js/Pages/Acl/Index.jsx
Normal file
120
resources/js/Pages/Acl/Index.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import AppLayout from '@/Layouts/AppLayout';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Users, Shield, Plus, Edit, Trash2, UserCheck } from 'lucide-react';
|
||||
import { GlassCard, StatCard } from '@/Components/ui/GlassCard';
|
||||
import { Button } from '@/Components/ui/Button';
|
||||
import { DataTable } from '@/Components/ui/DataTable';
|
||||
import { StatusBadge } from '@/Components/ui/StatusBadge';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function AclIndex({ users, roles }) {
|
||||
const [tab, setTab] = useState('users');
|
||||
|
||||
const userColumns = [
|
||||
{ header: 'User', accessor: 'name', render: row => (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={row.avatar_url} className="w-8 h-8 rounded-full ring-1 ring-white/20" alt={row.name} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{row.name}</p>
|
||||
<p className="text-xs text-white/40">{row.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)},
|
||||
{ header: 'Auth', accessor: 'auth_source', render: row => (
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded capitalize',
|
||||
row.auth_source === 'ldap' ? 'bg-purple-500/15 text-purple-400' : 'bg-white/5 text-white/50'
|
||||
)}>{row.auth_source}</span>
|
||||
)},
|
||||
{ header: 'Roles', key: 'roles', render: row => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(row.roles ?? []).map(r => (
|
||||
<span key={r} className="text-xs px-2 py-0.5 rounded bg-cyan-500/15 text-cyan-400">{r}</span>
|
||||
))}
|
||||
</div>
|
||||
)},
|
||||
{ header: 'Status', accessor: 'is_active', render: row => (
|
||||
<StatusBadge status={row.is_active ? 'active' : 'offline'} />
|
||||
)},
|
||||
{ header: 'Last Login', accessor: 'last_login_at', render: row => (
|
||||
<span className="text-xs text-white/40">{timeAgo(row.last_login_at)}</span>
|
||||
)},
|
||||
{ header: '', key: 'actions', render: row => (
|
||||
<div className="flex gap-1" onClick={e => e.stopPropagation()}>
|
||||
<a href={`/acl/users/${row.id}/edit`}><Button size="sm" variant="ghost" icon={Edit} /></a>
|
||||
<Button size="sm" variant="ghost" icon={Trash2}
|
||||
onClick={() => confirm('Delete this user?') && router.delete(`/acl/users/${row.id}`)} />
|
||||
</div>
|
||||
)},
|
||||
];
|
||||
|
||||
const roleColumns = [
|
||||
{ header: 'Role', accessor: 'name', render: row => (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white capitalize">{row.name}</p>
|
||||
<p className="text-xs text-white/30">{row.permissions_count ?? 0} permissions</p>
|
||||
</div>
|
||||
)},
|
||||
{ header: 'Users', key: 'users', render: row => (
|
||||
<span className="text-sm text-white/60">{row.users_count ?? 0}</span>
|
||||
)},
|
||||
{ header: '', key: 'actions', render: row => (
|
||||
<div className="flex gap-1">
|
||||
<a href={`/acl/roles/${row.id}/edit`}><Button size="sm" variant="ghost" icon={Edit} /></a>
|
||||
</div>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<AppLayout title="ACL">
|
||||
<Head title="Access Control" />
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Access Control</h2>
|
||||
<p className="text-sm text-white/40">Users, roles, and permissions management</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{tab === 'users' && <a href="/acl/users/create"><Button icon={Plus}>Add User</Button></a>}
|
||||
{tab === 'roles' && <a href="/acl/roles/create"><Button icon={Plus}>Add Role</Button></a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard title="Total Users" value={users?.total ?? 0} icon={Users} color="cyan" />
|
||||
<StatCard title="Active" value={users?.data?.filter(u => u.is_active).length ?? 0} icon={UserCheck} color="green" />
|
||||
<StatCard title="LDAP Users" value={users?.data?.filter(u => u.auth_source === 'ldap').length ?? 0} icon={Shield} color="purple" />
|
||||
<StatCard title="Roles" value={roles?.length ?? 0} icon={Shield} color="blue" />
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="flex gap-1 p-1 bg-white/5 rounded-xl w-fit border border-white/10 mb-4">
|
||||
{[{ value: 'users', label: 'Users' }, { value: 'roles', label: 'Roles & Permissions' }].map(t => (
|
||||
<TabsTrigger key={t.value} value={t.value}
|
||||
className={cn('px-5 py-2 rounded-lg text-sm font-medium transition-all',
|
||||
tab === t.value ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white'
|
||||
)}>{t.label}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<GlassCard className="p-5">
|
||||
<DataTable columns={userColumns} data={users?.data ?? []}
|
||||
onRowClick={row => router.visit(`/acl/users/${row.id}/edit`)}
|
||||
emptyMessage="No users found." />
|
||||
</GlassCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles">
|
||||
<GlassCard className="p-5">
|
||||
<DataTable columns={roleColumns} data={roles ?? []}
|
||||
emptyMessage="No roles defined." />
|
||||
</GlassCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user