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