From 579d88b123ecb01a10877594ae17d0ec893489ca Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 20:23:54 +0000 Subject: [PATCH] core-developer module --- app/Mod/Developer/Boot.php | 146 ++++++ .../Concerns/RemoteServerManager.php | 321 ++++++++++++ .../Console/Commands/CopyDeviceFrames.php | 72 +++ .../Developer/Controllers/DevController.php | 113 ++++ app/Mod/Developer/Data/RouteTestResult.php | 209 ++++++++ .../Exceptions/SshConnectionException.php | 30 ++ app/Mod/Developer/Lang/en_GB/developer.php | 130 +++++ .../Developer/Listeners/SetHadesCookie.php | 40 ++ .../Middleware/ApplyIconSettings.php | 34 ++ app/Mod/Developer/Middleware/RequireHades.php | 36 ++ ...1_01_01_000001_create_developer_tables.php | 43 ++ app/Mod/Developer/Models/Server.php | 182 +++++++ .../Providers/HorizonServiceProvider.php | 56 ++ .../Providers/TelescopeServiceProvider.php | 82 +++ app/Mod/Developer/Routes/admin.php | 51 ++ .../Developer/Services/LogReaderService.php | 290 +++++++++++ .../Developer/Services/RouteTestService.php | 411 +++++++++++++++ .../Developer/Tests/UseCase/DevToolsBasic.php | 82 +++ .../View/Blade/admin/activity-log.blade.php | 115 +++++ .../View/Blade/admin/cache.blade.php | 134 +++++ .../View/Blade/admin/database.blade.php | 134 +++++ .../Developer/View/Blade/admin/logs.blade.php | 95 ++++ .../Blade/admin/route-inspector.blade.php | 466 +++++++++++++++++ .../View/Blade/admin/routes.blade.php | 145 ++++++ .../View/Blade/admin/servers.blade.php | 221 ++++++++ .../Blade/vendor/pulse/dashboard.blade.php | 19 + .../View/Modal/Admin/ActivityLog.php | 122 +++++ app/Mod/Developer/View/Modal/Admin/Cache.php | 142 ++++++ .../Developer/View/Modal/Admin/Database.php | 153 ++++++ app/Mod/Developer/View/Modal/Admin/Logs.php | 110 ++++ .../View/Modal/Admin/RouteInspector.php | 482 ++++++++++++++++++ app/Mod/Developer/View/Modal/Admin/Routes.php | 101 ++++ .../Developer/View/Modal/Admin/Servers.php | 234 +++++++++ composer.json | 52 +- 34 files changed, 5013 insertions(+), 40 deletions(-) create mode 100644 app/Mod/Developer/Boot.php create mode 100644 app/Mod/Developer/Concerns/RemoteServerManager.php create mode 100644 app/Mod/Developer/Console/Commands/CopyDeviceFrames.php create mode 100644 app/Mod/Developer/Controllers/DevController.php create mode 100644 app/Mod/Developer/Data/RouteTestResult.php create mode 100644 app/Mod/Developer/Exceptions/SshConnectionException.php create mode 100644 app/Mod/Developer/Lang/en_GB/developer.php create mode 100644 app/Mod/Developer/Listeners/SetHadesCookie.php create mode 100644 app/Mod/Developer/Middleware/ApplyIconSettings.php create mode 100644 app/Mod/Developer/Middleware/RequireHades.php create mode 100644 app/Mod/Developer/Migrations/0001_01_01_000001_create_developer_tables.php create mode 100644 app/Mod/Developer/Models/Server.php create mode 100644 app/Mod/Developer/Providers/HorizonServiceProvider.php create mode 100644 app/Mod/Developer/Providers/TelescopeServiceProvider.php create mode 100644 app/Mod/Developer/Routes/admin.php create mode 100644 app/Mod/Developer/Services/LogReaderService.php create mode 100644 app/Mod/Developer/Services/RouteTestService.php create mode 100644 app/Mod/Developer/Tests/UseCase/DevToolsBasic.php create mode 100644 app/Mod/Developer/View/Blade/admin/activity-log.blade.php create mode 100644 app/Mod/Developer/View/Blade/admin/cache.blade.php create mode 100644 app/Mod/Developer/View/Blade/admin/database.blade.php create mode 100644 app/Mod/Developer/View/Blade/admin/logs.blade.php create mode 100644 app/Mod/Developer/View/Blade/admin/route-inspector.blade.php create mode 100644 app/Mod/Developer/View/Blade/admin/routes.blade.php create mode 100644 app/Mod/Developer/View/Blade/admin/servers.blade.php create mode 100644 app/Mod/Developer/View/Blade/vendor/pulse/dashboard.blade.php create mode 100644 app/Mod/Developer/View/Modal/Admin/ActivityLog.php create mode 100644 app/Mod/Developer/View/Modal/Admin/Cache.php create mode 100644 app/Mod/Developer/View/Modal/Admin/Database.php create mode 100644 app/Mod/Developer/View/Modal/Admin/Logs.php create mode 100644 app/Mod/Developer/View/Modal/Admin/RouteInspector.php create mode 100644 app/Mod/Developer/View/Modal/Admin/Routes.php create mode 100644 app/Mod/Developer/View/Modal/Admin/Servers.php diff --git a/app/Mod/Developer/Boot.php b/app/Mod/Developer/Boot.php new file mode 100644 index 0000000..51dd720 --- /dev/null +++ b/app/Mod/Developer/Boot.php @@ -0,0 +1,146 @@ + + */ + public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + ConsoleBooting::class => 'onConsole', + ]; + + public function boot(): void + { + $this->loadTranslationsFrom(__DIR__.'/Lang', 'developer'); + + app(AdminMenuRegistry::class)->register($this); + + $this->configureRateLimiting(); + + // Enable query logging in local environment for dev bar + if ($this->app->environment('local')) { + DB::enableQueryLog(); + } + } + + /** + * Configure rate limiters for developer API endpoints. + */ + protected function configureRateLimiting(): void + { + // Rate limit for cache clear operations: 10 per minute per user + // Prevents accidental rapid cache clears that could impact performance + RateLimiter::for('dev-cache-clear', function (Request $request) { + return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip()); + }); + + // Rate limit for log reading: 30 per minute per user + // Moderate limit as reading logs is read-only + RateLimiter::for('dev-logs', function (Request $request) { + return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip()); + }); + + // Rate limit for route listing: 30 per minute per user + // Read-only operation, moderate limit + RateLimiter::for('dev-routes', function (Request $request) { + return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip()); + }); + + // Rate limit for session info: 60 per minute per user + // Read-only operation, higher limit for debugging + RateLimiter::for('dev-session', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + } + + /** + * Admin menu items for this module. + */ + public function adminMenuItems(): array + { + return [ + // Admin menu (Hades only) + [ + 'group' => 'admin', + 'priority' => 80, + 'admin' => true, + 'item' => fn () => [ + 'label' => 'Dev Tools', + 'icon' => 'code', + 'color' => 'lime', + 'active' => request()->routeIs('hub.dev.*'), + 'children' => [ + ['label' => 'Logs', 'icon' => 'scroll', 'href' => route('hub.dev.logs'), 'active' => request()->routeIs('hub.dev.logs')], + ['label' => 'Activity', 'icon' => 'clock', 'href' => route('hub.dev.activity'), 'active' => request()->routeIs('hub.dev.activity')], + ['label' => 'Servers', 'icon' => 'server', 'href' => route('hub.dev.servers'), 'active' => request()->routeIs('hub.dev.servers')], + ['label' => 'Database', 'icon' => 'circle-stack', 'href' => route('hub.dev.database'), 'active' => request()->routeIs('hub.dev.database')], + ['label' => 'Routes', 'icon' => 'route', 'href' => route('hub.dev.routes'), 'active' => request()->routeIs('hub.dev.routes')], + ['label' => 'Route Inspector', 'icon' => 'beaker', 'href' => route('hub.dev.route-inspector'), 'active' => request()->routeIs('hub.dev.route-inspector')], + ['label' => 'Cache', 'icon' => 'database', 'href' => route('hub.dev.cache'), 'active' => request()->routeIs('hub.dev.cache')], + ], + ], + ], + ]; + } + + /** + * Global permissions required for Developer menu items. + */ + public function menuPermissions(): array + { + return []; // Items use 'admin' flag for Hades-only access + } + + /** + * Check if user can view Developer menu items. + */ + public function canViewMenu(?object $user, ?object $workspace): bool + { + return $user !== null; // Authenticated users - items filter by admin flag + } + + public function register(): void + { + // + } + + // ------------------------------------------------------------------------- + // Event-driven handlers + // ------------------------------------------------------------------------- + + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + // Override Pulse vendor views + view()->addNamespace('pulse', __DIR__.'/View/Blade/vendor/pulse'); + + if (file_exists(__DIR__.'/Routes/admin.php')) { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + } + + public function onConsole(ConsoleBooting $event): void + { + $event->command(Console\Commands\CopyDeviceFrames::class); + } +} diff --git a/app/Mod/Developer/Concerns/RemoteServerManager.php b/app/Mod/Developer/Concerns/RemoteServerManager.php new file mode 100644 index 0000000..c813b3e --- /dev/null +++ b/app/Mod/Developer/Concerns/RemoteServerManager.php @@ -0,0 +1,321 @@ +withConnection($this->server, function () { + * $this->run('cd /var/www && git pull'); + * $this->run('docker-compose up -d'); + * }); + * } + * } + */ +trait RemoteServerManager +{ + protected ?SSH2 $connection = null; + + protected ?Server $currentServer = null; + + /** + * Connect to a remote server via SSH. + * + * @throws SshConnectionException + */ + protected function connect(Server $server): SSH2 + { + // Verify workspace ownership before connecting + if (! $server->belongsToCurrentWorkspace()) { + throw new SshConnectionException( + 'Unauthorised access to server.', + $server->name + ); + } + + $ssh = new SSH2($server->ip, $server->port ?? 22); + $ssh->setTimeout(config('developer.ssh.connection_timeout', 30)); + + // Load the private key + $privateKey = $server->getDecryptedPrivateKey(); + if (! $privateKey) { + throw new SshConnectionException( + 'Server credentials not configured.', + $server->name + ); + } + + try { + $key = PublicKeyLoader::load($privateKey); + } catch (\Throwable) { + throw new SshConnectionException( + 'Invalid server credentials.', + $server->name + ); + } + + $username = $server->user ?? 'root'; + + if (! $ssh->login($username, $key)) { + $ssh->disconnect(); // Clean up socket on auth failure + throw new SshConnectionException( + 'SSH authentication failed.', + $server->name + ); + } + + $this->connection = $ssh; + $this->currentServer = $server; + + // Update server connection status with cleanup on failure + try { + $server->update([ + 'status' => 'connected', + 'last_connected_at' => now(), + ]); + } catch (\Throwable $e) { + $this->disconnect(); + throw $e; + } + + return $ssh; + } + + /** + * Execute operations with guaranteed connection cleanup. + * + * Usage: + * $result = $this->withConnection($server, function () { + * $this->run('git pull'); + * return $this->run('docker-compose up -d'); + * }); + * + * @template T + * + * @param callable(): T $callback + * @return T + * + * @throws SshConnectionException + */ + protected function withConnection(Server $server, callable $callback): mixed + { + try { + $this->connect($server); + + return $callback(); + } finally { + $this->disconnect(); + } + } + + /** + * Execute a command on the remote server via SSH. + * + * Note: This uses phpseclib's SSH2::exec() method which executes + * commands on the REMOTE server over SSH, not locally. + * + * @throws SshConnectionException + */ + protected function run(string $command, ?int $timeout = null): CommandResult + { + if (! $this->connection) { + throw new SshConnectionException('Not connected to any server.'); + } + + $timeout ??= config('developer.ssh.command_timeout', 60); + $this->connection->setTimeout($timeout); + + // Execute command on remote server via SSH and capture output + $output = $this->connection->exec($command); + $exitCode = $this->connection->getExitStatus() ?? 0; + + return new CommandResult( + output: $output ?: '', + exitCode: $exitCode + ); + } + + /** + * Execute a command and return success/failure. + * + * @throws SshConnectionException + */ + protected function runQuietly(string $command, ?int $timeout = null): bool + { + return $this->run($command, $timeout)->isSuccessful(); + } + + /** + * Execute multiple commands in sequence. + * + * @param array $commands + * @return array + * + * @throws SshConnectionException + */ + protected function runMany(array $commands, ?int $timeout = null): array + { + $results = []; + + foreach ($commands as $command) { + $result = $this->run($command, $timeout); + $results[] = $result; + + // Stop if a command fails + if ($result->isFailed()) { + break; + } + } + + return $results; + } + + /** + * Check if a file exists on the remote server. + * + * @throws SshConnectionException + */ + protected function fileExists(string $path): bool + { + $escapedPath = escapeshellarg($path); + $result = $this->run("test -f {$escapedPath} && echo 'exists'"); + + return $result->contains('exists'); + } + + /** + * Check if a directory exists on the remote server. + * + * @throws SshConnectionException + */ + protected function directoryExists(string $path): bool + { + $escapedPath = escapeshellarg($path); + $result = $this->run("test -d {$escapedPath} && echo 'exists'"); + + return $result->contains('exists'); + } + + /** + * Read a file from the remote server. + * + * @throws SshConnectionException + */ + protected function readFile(string $path): string + { + $escapedPath = escapeshellarg($path); + $result = $this->run("cat {$escapedPath}"); + + if ($result->isFailed()) { + throw new SshConnectionException( + "Failed to read file: {$path}", + $this->currentServer?->name + ); + } + + return $result->output; + } + + /** + * Write content to a file on the remote server. + * + * Uses base64 encoding to safely transfer content without shell injection. + * + * @throws SshConnectionException + */ + protected function writeFile(string $path, string $content): bool + { + $escapedPath = escapeshellarg($path); + $encoded = base64_encode($content); + + return $this->run("echo {$encoded} | base64 -d > {$escapedPath}")->isSuccessful(); + } + + /** + * Get the current server's disk usage. + * + * @throws SshConnectionException + */ + protected function getDiskUsage(string $path = '/'): array + { + $escapedPath = escapeshellarg($path); + $result = $this->run("df -h {$escapedPath} | tail -1 | awk '{print \$2, \$3, \$4, \$5}'"); + + if ($result->isFailed()) { + return []; + } + + $parts = preg_split('/\s+/', trim($result->output)); + + return [ + 'total' => $parts[0] ?? 'unknown', + 'used' => $parts[1] ?? 'unknown', + 'available' => $parts[2] ?? 'unknown', + 'percentage' => $parts[3] ?? 'unknown', + ]; + } + + /** + * Get the current server's memory usage. + * + * @throws SshConnectionException + */ + protected function getMemoryUsage(): array + { + $result = $this->run("free -h | grep 'Mem:' | awk '{print \$2, \$3, \$4}'"); + + if ($result->isFailed()) { + return []; + } + + $parts = preg_split('/\s+/', trim($result->output)); + + return [ + 'total' => $parts[0] ?? 'unknown', + 'used' => $parts[1] ?? 'unknown', + 'free' => $parts[2] ?? 'unknown', + ]; + } + + /** + * Disconnect from the remote server. + */ + protected function disconnect(): void + { + if ($this->connection) { + $this->connection->disconnect(); + $this->connection = null; + } + + $this->currentServer = null; + } + + /** + * Check if currently connected to a server. + */ + protected function isConnected(): bool + { + return $this->connection !== null && $this->connection->isConnected(); + } + + /** + * Get the current SSH connection. + */ + protected function getConnection(): ?SSH2 + { + return $this->connection; + } +} diff --git a/app/Mod/Developer/Console/Commands/CopyDeviceFrames.php b/app/Mod/Developer/Console/Commands/CopyDeviceFrames.php new file mode 100644 index 0000000..2e8a3eb --- /dev/null +++ b/app/Mod/Developer/Console/Commands/CopyDeviceFrames.php @@ -0,0 +1,72 @@ +error("Source directory not found: {$sourcePath}"); + + return Command::FAILURE; + } + + // Create destination directory + if (! File::isDirectory($publicPath)) { + File::makeDirectory($publicPath, 0755, true); + } + + $copied = 0; + $skipped = 0; + $failed = 0; + + foreach ($config['devices'] as $deviceSlug => $device) { + $deviceDir = "{$publicPath}/{$deviceSlug}"; + + if (! File::isDirectory($deviceDir)) { + File::makeDirectory($deviceDir, 0755, true); + } + + foreach ($device['variants'] as $variantSlug => $variant) { + $extension = $device['format']; + $sourceFile = "{$sourcePath}/{$device['path']}/{$variant['file']}.{$extension}"; + $destFile = "{$deviceDir}/{$variantSlug}.{$extension}"; + + if (! File::exists($sourceFile)) { + $this->warn("Source not found: {$sourceFile}"); + $failed++; + + continue; + } + + if (File::exists($destFile) && ! $this->option('force')) { + $this->line("Skipping: {$deviceSlug}/{$variantSlug}.{$extension}"); + $skipped++; + + continue; + } + + File::copy($sourceFile, $destFile); + $this->line("Copied: {$deviceSlug}/{$variantSlug}.{$extension}"); + $copied++; + } + } + + $this->newLine(); + $this->info("Done! Copied: {$copied}, Skipped: {$skipped}, Failed: {$failed}"); + + return Command::SUCCESS; + } +} diff --git a/app/Mod/Developer/Controllers/DevController.php b/app/Mod/Developer/Controllers/DevController.php new file mode 100644 index 0000000..1bc57ea --- /dev/null +++ b/app/Mod/Developer/Controllers/DevController.php @@ -0,0 +1,113 @@ +logReader->getDefaultLogPath(); + $logs = $this->logReader->readLogEntries($logFile, maxLines: 100); + + // Truncate messages and return most recent first, limited to 20 + $logs = array_map(function (array $log): array { + $log['message'] = Str::limit($log['message'], 200); + + return $log; + }, $logs); + + return response()->json(array_slice(array_reverse($logs), 0, 20)); + } + + /** + * Get all routes. + */ + public function routes(): JsonResponse + { + $this->authorize(); + + $routes = collect(Route::getRoutes())->map(function ($route) { + $methods = $route->methods(); + $method = $methods[0] ?? 'ANY'; + + // Skip HEAD method entries + if ($method === 'HEAD') { + return null; + } + + return [ + 'method' => $method, + 'uri' => '/'.ltrim($route->uri(), '/'), + 'name' => $route->getName(), + 'action' => $route->getActionName(), + ]; + })->filter()->values()->toArray(); + + return response()->json($routes); + } + + /** + * Get session and request info. + */ + public function session(Request $request): JsonResponse + { + $this->authorize(); + + return response()->json([ + 'id' => session()->getId(), + 'ip' => $request->ip(), + 'user_agent' => Str::limit($request->userAgent(), 100), + 'method' => $request->method(), + 'url' => $request->fullUrl(), + ]); + } + + /** + * Clear cache. + */ + public function clear(string $type): JsonResponse + { + $this->authorize(); + + $commands = [ + 'cache' => 'cache:clear', + 'config' => 'config:clear', + 'view' => 'view:clear', + 'route' => 'route:clear', + 'all' => ['cache:clear', 'config:clear', 'view:clear', 'route:clear'], + ]; + + if (! isset($commands[$type])) { + return response()->json(['message' => 'Invalid cache type'], 400); + } + + $toRun = is_array($commands[$type]) ? $commands[$type] : [$commands[$type]]; + $output = []; + + foreach ($toRun as $command) { + Artisan::call($command); + $output[] = trim(Artisan::output()); + } + + return response()->json([ + 'message' => implode("\n", $output), + 'type' => $type, + ]); + } +} diff --git a/app/Mod/Developer/Data/RouteTestResult.php b/app/Mod/Developer/Data/RouteTestResult.php new file mode 100644 index 0000000..05a99a2 --- /dev/null +++ b/app/Mod/Developer/Data/RouteTestResult.php @@ -0,0 +1,209 @@ + $headers Response headers + * @param string $body Response body + * @param float $responseTime Time taken in milliseconds + * @param int $memoryUsage Memory used in bytes + * @param Throwable|null $exception Exception if request failed + * @param string $method HTTP method used + * @param string $uri URI tested + */ + public function __construct( + public int $statusCode, + public array $headers, + public string $body, + public float $responseTime, + public int $memoryUsage, + public ?Throwable $exception = null, + public string $method = 'GET', + public string $uri = '', + ) {} + + /** + * Check if the request was successful (2xx status code). + */ + public function isSuccessful(): bool + { + return $this->statusCode >= 200 && $this->statusCode < 300; + } + + /** + * Check if the request resulted in a redirect (3xx status code). + */ + public function isRedirect(): bool + { + return $this->statusCode >= 300 && $this->statusCode < 400; + } + + /** + * Check if the request resulted in a client error (4xx status code). + */ + public function isClientError(): bool + { + return $this->statusCode >= 400 && $this->statusCode < 500; + } + + /** + * Check if the request resulted in a server error (5xx status code). + */ + public function isServerError(): bool + { + return $this->statusCode >= 500; + } + + /** + * Check if an exception occurred during the request. + */ + public function hasException(): bool + { + return $this->exception !== null; + } + + /** + * Get the status text for the status code. + */ + public function getStatusText(): string + { + return Response::$statusTexts[$this->statusCode] ?? 'Unknown'; + } + + /** + * Get formatted response time. + */ + public function getFormattedResponseTime(): string + { + if ($this->responseTime < 1) { + return round($this->responseTime * 1000, 2).'μs'; + } + + if ($this->responseTime < 1000) { + return round($this->responseTime, 2).'ms'; + } + + return round($this->responseTime / 1000, 2).'s'; + } + + /** + * Get formatted memory usage. + */ + public function getFormattedMemoryUsage(): string + { + $bytes = $this->memoryUsage; + + if ($bytes < 1024) { + return $bytes.' B'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 2).' KB'; + } + + return round($bytes / 1048576, 2).' MB'; + } + + /** + * Get the content type from headers. + */ + public function getContentType(): string + { + $contentType = $this->headers['Content-Type'] + ?? $this->headers['content-type'] + ?? 'text/plain'; + + // Extract just the mime type (without charset, etc.) + return explode(';', $contentType)[0]; + } + + /** + * Check if response is JSON. + */ + public function isJson(): bool + { + return str_contains($this->getContentType(), 'json'); + } + + /** + * Check if response is HTML. + */ + public function isHtml(): bool + { + return str_contains($this->getContentType(), 'html'); + } + + /** + * Get formatted body for display. + * + * Attempts to pretty-print JSON responses. + */ + public function getFormattedBody(): string + { + if ($this->isJson()) { + $decoded = json_decode($this->body, true); + if (json_last_error() === JSON_ERROR_NONE) { + return json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + } + + return $this->body; + } + + /** + * Get body truncated to a maximum length. + */ + public function getTruncatedBody(int $maxLength = 10000): string + { + $body = $this->getFormattedBody(); + + if (strlen($body) <= $maxLength) { + return $body; + } + + return substr($body, 0, $maxLength)."\n\n... (truncated, total: ".strlen($this->body).' bytes)'; + } + + /** + * Convert to array for serialisation. + */ + public function toArray(): array + { + return [ + 'status_code' => $this->statusCode, + 'status_text' => $this->getStatusText(), + 'method' => $this->method, + 'uri' => $this->uri, + 'headers' => $this->headers, + 'body' => $this->body, + 'body_length' => strlen($this->body), + 'content_type' => $this->getContentType(), + 'response_time' => $this->responseTime, + 'response_time_formatted' => $this->getFormattedResponseTime(), + 'memory_usage' => $this->memoryUsage, + 'memory_usage_formatted' => $this->getFormattedMemoryUsage(), + 'is_successful' => $this->isSuccessful(), + 'is_json' => $this->isJson(), + 'exception' => $this->exception ? [ + 'class' => get_class($this->exception), + 'message' => $this->exception->getMessage(), + 'file' => $this->exception->getFile(), + 'line' => $this->exception->getLine(), + ] : null, + ]; + } +} diff --git a/app/Mod/Developer/Exceptions/SshConnectionException.php b/app/Mod/Developer/Exceptions/SshConnectionException.php new file mode 100644 index 0000000..96a62a6 --- /dev/null +++ b/app/Mod/Developer/Exceptions/SshConnectionException.php @@ -0,0 +1,30 @@ +serverName; + } +} diff --git a/app/Mod/Developer/Lang/en_GB/developer.php b/app/Mod/Developer/Lang/en_GB/developer.php new file mode 100644 index 0000000..eeaa3ac --- /dev/null +++ b/app/Mod/Developer/Lang/en_GB/developer.php @@ -0,0 +1,130 @@ + [ + 'title' => 'Application logs', + 'actions' => [ + 'refresh' => 'Refresh', + 'download' => 'Download', + 'clear' => 'Clear logs', + ], + 'levels' => [ + 'error' => 'Error', + 'warning' => 'Warning', + 'info' => 'Info', + 'debug' => 'Debug', + ], + 'clear_filter' => 'Clear filter', + 'empty' => 'No log entries found.', + ], + + // Application Routes + 'routes' => [ + 'title' => 'Application routes', + 'count' => ':count routes', + 'search_placeholder' => 'Search routes...', + 'clear' => 'Clear', + 'table' => [ + 'method' => 'Method', + 'uri' => 'URI', + 'name' => 'Name', + 'action' => 'Action', + ], + 'empty' => 'No routes found matching your criteria.', + ], + + // Cache Management + 'cache' => [ + 'title' => 'Cache management', + 'cards' => [ + 'application' => [ + 'title' => 'Application cache', + 'description' => 'Clear cached data', + 'action' => 'Clear cache', + ], + 'config' => [ + 'title' => 'Config cache', + 'description' => 'Clear configuration cache', + 'action' => 'Clear config', + ], + 'view' => [ + 'title' => 'View cache', + 'description' => 'Clear compiled Blade views', + 'action' => 'Clear views', + ], + 'route' => [ + 'title' => 'Route cache', + 'description' => 'Clear route cache', + 'action' => 'Clear routes', + ], + 'all' => [ + 'title' => 'Clear all', + 'description' => 'Clear all caches at once', + 'action' => 'Clear all caches', + ], + 'optimise' => [ + 'title' => 'Optimise', + 'description' => 'Cache config, routes & views', + 'action' => 'Optimise', + ], + ], + 'last_action' => 'Last action', + ], + + // Route Inspector + 'route_inspector' => [ + 'title' => 'Route inspector', + 'description' => 'Test and inspect application routes interactively.', + 'search_placeholder' => 'Search routes...', + 'filters' => [ + 'clear' => 'Clear filters', + ], + 'table' => [ + 'method' => 'Method', + 'uri' => 'URI', + 'name' => 'Name', + 'actions' => 'Actions', + ], + 'actions' => [ + 'test' => 'Test', + 'inspect' => 'Inspect', + 'execute' => 'Execute request', + 'copy_curl' => 'Copy as cURL', + 'copy_response' => 'Copy response', + ], + 'request' => [ + 'title' => 'Request builder', + 'route_params' => 'Route parameters', + 'query_params' => 'Query parameters', + 'body' => 'Request body (JSON)', + 'headers' => 'Custom headers', + 'add_param' => 'Add parameter', + 'use_auth' => 'Use current session authentication', + ], + 'response' => [ + 'title' => 'Response', + 'headers' => 'Headers', + 'body' => 'Body', + ], + 'history' => [ + 'title' => 'Recent tests', + 'clear' => 'Clear history', + 'empty' => 'No tests run yet.', + ], + 'warnings' => [ + 'testing_disabled' => 'Route testing is only available in local and testing environments.', + 'destructive' => 'This is a :method request. It may modify data in your local database.', + 'requires_auth' => 'Route requires authentication', + ], + 'empty' => 'No routes found matching your criteria.', + ], +]; diff --git a/app/Mod/Developer/Listeners/SetHadesCookie.php b/app/Mod/Developer/Listeners/SetHadesCookie.php new file mode 100644 index 0000000..10e7250 --- /dev/null +++ b/app/Mod/Developer/Listeners/SetHadesCookie.php @@ -0,0 +1,40 @@ + Blade component. + */ + public function handle(Request $request, Closure $next): Response + { + try { + // Read from cookie (synced from localStorage by JS) or use defaults + $iconStyle = $request->cookie('icon-style', 'fa-notdog fa-solid'); + $iconSize = $request->cookie('icon-size', 'fa-lg'); + + // Store in session for Blade component access + // Wrapped in try-catch to handle session errors gracefully + session(['icon-style' => $iconStyle, 'icon-size' => $iconSize]); + } catch (\Throwable) { + // Session write failed - continue without icon settings + // ResilientSession middleware will handle the actual error + } + + return $next($request); + } +} diff --git a/app/Mod/Developer/Middleware/RequireHades.php b/app/Mod/Developer/Middleware/RequireHades.php new file mode 100644 index 0000000..b507d15 --- /dev/null +++ b/app/Mod/Developer/Middleware/RequireHades.php @@ -0,0 +1,36 @@ +group(function () { + * Route::get('/dev/logs', ...); + * }); + */ +class RequireHades +{ + /** + * Handle an incoming request. + */ + public function handle(Request $request, Closure $next): Response + { + $user = $request->user(); + + if (! $user || ! $user->isHades()) { + abort(403, 'Hades access required'); + } + + return $next($request); + } +} diff --git a/app/Mod/Developer/Migrations/0001_01_01_000001_create_developer_tables.php b/app/Mod/Developer/Migrations/0001_01_01_000001_create_developer_tables.php new file mode 100644 index 0000000..b550589 --- /dev/null +++ b/app/Mod/Developer/Migrations/0001_01_01_000001_create_developer_tables.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('name', 128); + $table->string('ip', 45); + $table->unsignedSmallInteger('port')->default(22); + $table->string('user', 64)->default('root'); + $table->text('private_key')->nullable(); + $table->string('status', 32)->default('pending'); + $table->timestamp('last_connected_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('workspace_id'); + $table->index(['workspace_id', 'status']); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::dropIfExists('servers'); + } +}; diff --git a/app/Mod/Developer/Models/Server.php b/app/Mod/Developer/Models/Server.php new file mode 100644 index 0000000..a4bf591 --- /dev/null +++ b/app/Mod/Developer/Models/Server.php @@ -0,0 +1,182 @@ + + */ + protected $fillable = [ + 'workspace_id', + 'name', + 'ip', + 'port', + 'user', + 'private_key', + 'status', + 'last_connected_at', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'private_key', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'port' => 'integer', + 'last_connected_at' => 'datetime', + 'private_key' => 'encrypted', + ]; + + /** + * Default attribute values. + * + * @var array + */ + protected $attributes = [ + 'port' => 22, + 'user' => 'root', + 'status' => 'pending', + ]; + + /** + * Get the decrypted private key. + * + * Note: With the 'encrypted' cast, $this->private_key is automatically decrypted. + * This method provides a null-safe accessor. + */ + public function getDecryptedPrivateKey(): ?string + { + return $this->private_key; + } + + /** + * Check if the server has a valid private key. + */ + public function hasPrivateKey(): bool + { + return $this->getDecryptedPrivateKey() !== null; + } + + /** + * Get the server's connection string. + */ + public function getConnectionStringAttribute(): string + { + return "{$this->user}@{$this->ip}:{$this->port}"; + } + + /** + * Check if the server is connected. + */ + public function isConnected(): bool + { + return $this->status === 'connected'; + } + + /** + * Check if the server is in a failed state. + */ + public function isFailed(): bool + { + return $this->status === 'failed'; + } + + /** + * Mark the server as failed. + */ + public function markAsFailed(?string $reason = null): void + { + $this->update([ + 'status' => 'failed', + ]); + + if ($reason) { + activity() + ->performedOn($this) + ->withProperties(['reason' => $reason]) + ->log('Server connection failed'); + } + } + + /** + * Configure activity logging. + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'ip', 'port', 'user', 'status']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + /** + * Scope to only connected servers. + */ + public function scopeConnected(Builder $query): Builder + { + return $query->where('status', 'connected'); + } + + /** + * Scope to only failed servers. + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('status', 'failed'); + } + + /** + * Scope to only pending servers. + */ + public function scopePending(Builder $query): Builder + { + return $query->where('status', 'pending'); + } +} diff --git a/app/Mod/Developer/Providers/HorizonServiceProvider.php b/app/Mod/Developer/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..e348d55 --- /dev/null +++ b/app/Mod/Developer/Providers/HorizonServiceProvider.php @@ -0,0 +1,56 @@ +configureNotifications(); + } + + /** + * Configure Horizon notification routing from config. + */ + protected function configureNotifications(): void + { + $smsTo = config('developer.horizon.sms_to'); + if ($smsTo) { + Horizon::routeSmsNotificationsTo($smsTo); + } + + $mailTo = config('developer.horizon.mail_to'); + if ($mailTo) { + Horizon::routeMailNotificationsTo($mailTo); + } + + $slackWebhook = config('developer.horizon.slack_webhook'); + if ($slackWebhook) { + $slackChannel = config('developer.horizon.slack_channel', '#alerts'); + Horizon::routeSlackNotificationsTo($slackWebhook, $slackChannel); + } + } + + /** + * Register the Horizon gate. + * + * This gate determines who can access Horizon in non-local environments. + */ + protected function gate(): void + { + Gate::define('viewHorizon', function ($user = null) { + return $user?->isHades() ?? false; + }); + } +} diff --git a/app/Mod/Developer/Providers/TelescopeServiceProvider.php b/app/Mod/Developer/Providers/TelescopeServiceProvider.php new file mode 100644 index 0000000..df3c8d4 --- /dev/null +++ b/app/Mod/Developer/Providers/TelescopeServiceProvider.php @@ -0,0 +1,82 @@ +gate(); + } + + /** + * Register any application services. + */ + public function register(): void + { + // Skip if Telescope is not installed (production without dev dependencies) + if (! class_exists(\Laravel\Telescope\Telescope::class)) { + return; + } + + $this->hideSensitiveRequestDetails(); + + $isLocal = $this->app->environment('local'); + + \Laravel\Telescope\Telescope::filter(function (\Laravel\Telescope\IncomingEntry $entry) use ($isLocal) { + return $isLocal || + $entry->isReportableException() || + $entry->isFailedRequest() || + $entry->isFailedJob() || + $entry->isScheduledTask() || + $entry->hasMonitoredTag(); + }); + } + + /** + * Prevent sensitive request details from being logged by Telescope. + */ + protected function hideSensitiveRequestDetails(): void + { + if ($this->app->environment('local')) { + return; + } + + \Laravel\Telescope\Telescope::hideRequestParameters(['_token']); + + \Laravel\Telescope\Telescope::hideRequestHeaders([ + 'cookie', + 'x-csrf-token', + 'x-xsrf-token', + ]); + } + + /** + * Register the Telescope gate. + */ + protected function gate(): void + { + Gate::define('viewTelescope', function ($user = null) { + // Always allow in local environment + if (app()->environment('local')) { + return true; + } + + // In production, require Hades tier + return $user?->isHades() ?? false; + }); + } +} diff --git a/app/Mod/Developer/Routes/admin.php b/app/Mod/Developer/Routes/admin.php new file mode 100644 index 0000000..e625dba --- /dev/null +++ b/app/Mod/Developer/Routes/admin.php @@ -0,0 +1,51 @@ +name('hub.')->group(function () { + // Developer tools (Hades only) - authorization checked in components + Route::prefix('dev')->name('dev.')->group(function () { + Route::get('/logs', \Mod\Developer\View\Modal\Admin\Logs::class)->name('logs'); + Route::get('/routes', \Mod\Developer\View\Modal\Admin\Routes::class)->name('routes'); + Route::get('/cache', \Mod\Developer\View\Modal\Admin\Cache::class)->name('cache'); + Route::get('/activity', \Mod\Developer\View\Modal\Admin\ActivityLog::class)->name('activity'); + Route::get('/servers', \Mod\Developer\View\Modal\Admin\Servers::class)->name('servers'); + Route::get('/database', \Mod\Developer\View\Modal\Admin\Database::class)->name('database'); + Route::get('/route-inspector', \Mod\Developer\View\Modal\Admin\RouteInspector::class)->name('route-inspector'); + }); +}); + +/* +|-------------------------------------------------------------------------- +| Developer API Routes +|-------------------------------------------------------------------------- +| These routes use the RequireHades middleware for authorization and +| rate limiting to prevent abuse of sensitive operations. +*/ + +Route::prefix('hub/api/dev') + ->name('hub.api.dev.') + ->middleware(\Mod\Developer\Middleware\RequireHades::class) + ->group(function () { + Route::get('/logs', [\Mod\Developer\Controllers\DevController::class, 'logs']) + ->middleware('throttle:dev-logs') + ->name('logs'); + + Route::get('/routes', [\Mod\Developer\Controllers\DevController::class, 'routes']) + ->middleware('throttle:dev-routes') + ->name('routes'); + + Route::get('/session', [\Mod\Developer\Controllers\DevController::class, 'session']) + ->middleware('throttle:dev-session') + ->name('session'); + + Route::post('/clear/{type}', [\Mod\Developer\Controllers\DevController::class, 'clear']) + ->middleware('throttle:dev-cache-clear') + ->name('clear'); + }); diff --git a/app/Mod/Developer/Services/LogReaderService.php b/app/Mod/Developer/Services/LogReaderService.php new file mode 100644 index 0000000..5bd4650 --- /dev/null +++ b/app/Mod/Developer/Services/LogReaderService.php @@ -0,0 +1,290 @@ + '[STRIPE_KEY_REDACTED]', + '/\b(ghp_|gho_|ghu_|ghs_|ghr_)[a-zA-Z0-9]{36,}\b/' => '[GITHUB_TOKEN_REDACTED]', + '/\bBearer\s+[a-zA-Z0-9\-_\.]{20,}\b/i' => 'Bearer [TOKEN_REDACTED]', + '/\b(api[_-]?key|apikey)\s*[=:]\s*["\']?[a-zA-Z0-9\-_]{16,}["\']?/i' => '$1=[KEY_REDACTED]', + '/\b(secret|token|password|passwd|pwd)\s*[=:]\s*["\']?[^\s"\']{8,}["\']?/i' => '$1=[REDACTED]', + + // AWS credentials + '/\b(AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}\b/' => '[AWS_KEY_REDACTED]', + '/\b[a-zA-Z0-9\/+]{40}\b(?=.*aws)/i' => '[AWS_SECRET_REDACTED]', + + // Database connection strings + '/mysql:\/\/[^:]+:[^@]+@/' => 'mysql://[USER]:[PASS]@', + '/pgsql:\/\/[^:]+:[^@]+@/' => 'pgsql://[USER]:[PASS]@', + '/mongodb:\/\/[^:]+:[^@]+@/' => 'mongodb://[USER]:[PASS]@', + '/redis:\/\/[^:]+:[^@]+@/' => 'redis://[USER]:[PASS]@', + + // Email addresses (partial redaction) + '/\b([a-zA-Z0-9._%+-]{2})[a-zA-Z0-9._%+-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/' => '$1***@$2', + + // IP addresses (partial redaction for privacy) + '/\b(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}\b/' => '$1.$2.xxx.xxx', + + // Credit card numbers (basic patterns) + '/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/' => '[CARD_REDACTED]', + + // JWT tokens + '/\beyJ[a-zA-Z0-9\-_]+\.eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\b/' => '[JWT_REDACTED]', + + // Private keys + '/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(RSA\s+)?PRIVATE\s+KEY-----/' => '[PRIVATE_KEY_REDACTED]', + + // Common env var patterns in stack traces + '/(DB_PASSWORD|MAIL_PASSWORD|REDIS_PASSWORD|AWS_SECRET)["\']?\s*=>\s*["\']?[^"\'}\s]+["\']?/i' => '$1 => [REDACTED]', + ]; + + /** + * Read the last N lines from a file efficiently. + * + * Uses a backwards-reading approach to avoid loading large files into memory. + * + * @param string $filepath Path to the file + * @param int $lines Number of lines to read + * @param int $bufferSize Bytes to read at a time + * @return array + */ + public function tailFile(string $filepath, int $lines = 100, int $bufferSize = 4096): array + { + if (! file_exists($filepath)) { + return []; + } + + $handle = fopen($filepath, 'r'); + if ($handle === false) { + return []; + } + + fseek($handle, 0, SEEK_END); + $pos = ftell($handle); + + $result = []; + $buffer = ''; + + while ($pos > 0 && count($result) < $lines) { + $readSize = min($bufferSize, $pos); + $pos -= $readSize; + fseek($handle, $pos); + $buffer = fread($handle, $readSize).$buffer; + + $bufferLines = explode("\n", $buffer); + $buffer = array_shift($bufferLines) ?? ''; + + foreach (array_reverse($bufferLines) as $line) { + if ($line !== '') { + array_unshift($result, $line); + if (count($result) >= $lines) { + break; + } + } + } + } + + if ($buffer !== '' && count($result) < $lines) { + array_unshift($result, $buffer); + } + + fclose($handle); + + return array_slice($result, -$lines); + } + + /** + * Read and parse Laravel log entries from end of file. + * + * @param string $logFile Path to the log file + * @param int $maxLines Maximum lines to read from file + * @param int $maxBytes Maximum bytes to read from end of file + * @param string|null $levelFilter Optional level filter (debug, info, warning, error, etc.) + * @return array + */ + public function readLogEntries( + string $logFile, + int $maxLines = 500, + int $maxBytes = 102400, + ?string $levelFilter = null + ): array { + if (! file_exists($logFile)) { + return []; + } + + $handle = fopen($logFile, 'r'); + if (! $handle) { + return []; + } + + $fileSize = filesize($logFile); + + if ($fileSize > $maxBytes) { + fseek($handle, -$maxBytes, SEEK_END); + fgets($handle); // Skip partial first line + } + + $lines = []; + while (($line = fgets($handle)) !== false) { + $line = trim($line); + if (! empty($line)) { + $lines[] = $line; + } + } + fclose($handle); + + $lines = array_slice($lines, -$maxLines); + $logs = []; + + foreach ($lines as $line) { + if (preg_match("/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/", $line, $matches)) { + $level = strtolower($matches[2]); + + if ($levelFilter && $level !== $levelFilter) { + continue; + } + + $logs[] = [ + 'time' => $matches[1], + 'level' => $level, + 'message' => $this->redactSensitiveData($matches[3]), + ]; + } + } + + return $logs; + } + + /** + * Redact sensitive data from a string. + * + * Applies all configured redaction patterns to protect sensitive + * information from being displayed in logs. + */ + public function redactSensitiveData(string $text): string + { + foreach (self::REDACTION_PATTERNS as $pattern => $replacement) { + $text = preg_replace($pattern, $replacement, $text) ?? $text; + } + + return $text; + } + + /** + * Get the default Laravel log file path. + */ + public function getDefaultLogPath(): string + { + return storage_path('logs/laravel.log'); + } + + /** + * Get all available log files in the logs directory. + * + * Returns log files sorted by modification time (newest first). + * + * @return array + */ + public function getAvailableLogFiles(): array + { + $logDir = storage_path('logs'); + if (! is_dir($logDir)) { + return []; + } + + $files = []; + $patterns = ['*.log', '*.log.*']; + + foreach ($patterns as $pattern) { + $matches = glob("{$logDir}/{$pattern}"); + if ($matches) { + foreach ($matches as $file) { + if (is_file($file)) { + $files[$file] = [ + 'name' => basename($file), + 'path' => $file, + 'size' => filesize($file), + 'modified' => filemtime($file), + ]; + } + } + } + } + + // Sort by modification time, newest first + usort($files, fn ($a, $b) => $b['modified'] <=> $a['modified']); + + return array_values($files); + } + + /** + * Get the current log file based on Laravel's logging configuration. + * + * Handles both single and daily log channels. + */ + public function getCurrentLogPath(): string + { + $channel = config('logging.default'); + $channelConfig = config("logging.channels.{$channel}"); + + $driver = $channelConfig['driver'] ?? null; + + // Single driver uses the configured path directly + if ($driver === 'single') { + return $channelConfig['path'] ?? storage_path('logs/laravel.log'); + } + + // Daily driver uses dated log files + if ($driver === 'daily') { + $path = $channelConfig['path'] ?? storage_path('logs/laravel.log'); + $baseName = pathinfo($path, PATHINFO_FILENAME); + $dir = pathinfo($path, PATHINFO_DIRNAME); + $ext = pathinfo($path, PATHINFO_EXTENSION); + + $todayLog = "{$dir}/{$baseName}-".date('Y-m-d').".{$ext}"; + + if (file_exists($todayLog)) { + return $todayLog; + } + + // Fall back to configured path if today's log doesn't exist yet + return $path; + } + + // Fallback for other drivers or when no channel config exists + return $this->getDefaultLogPath(); + } + + /** + * Clear the contents of a log file. + * + * @return int|false The previous file size in bytes, or false on failure + */ + public function clearLogFile(string $logFile): int|false + { + if (! file_exists($logFile)) { + return false; + } + + $previousSize = filesize($logFile); + $result = file_put_contents($logFile, ''); + + return $result !== false ? $previousSize : false; + } +} diff --git a/app/Mod/Developer/Services/RouteTestService.php b/app/Mod/Developer/Services/RouteTestService.php new file mode 100644 index 0000000..85c9055 --- /dev/null +++ b/app/Mod/Developer/Services/RouteTestService.php @@ -0,0 +1,411 @@ + 'application/json', + 'X-Requested-With' => 'XMLHttpRequest', + ]; + + /** + * Check if route testing is allowed in current environment. + */ + public function isTestingAllowed(): bool + { + return App::environment(['local', 'testing']); + } + + /** + * Get all available routes for testing. + * + * @return array + */ + public function getRoutes(): array + { + return collect(RouteFacade::getRoutes()) + ->map(fn (Route $route) => $this->formatRoute($route)) + ->filter() + ->values() + ->toArray(); + } + + /** + * Get a specific route by its name or URI. + */ + public function findRoute(string $name, ?string $method = null): ?Route + { + $routes = RouteFacade::getRoutes(); + + // First try to find by name + if ($route = $routes->getByName($name)) { + return $route; + } + + // Then try to find by URI + foreach ($routes as $route) { + $uri = '/'.ltrim($route->uri(), '/'); + if ($uri === $name || $route->uri() === ltrim($name, '/')) { + if ($method === null || in_array(strtoupper($method), $route->methods())) { + return $route; + } + } + } + + return null; + } + + /** + * Format a route for display. + */ + public function formatRoute(Route $route): ?array + { + $methods = $route->methods(); + $method = $methods[0] ?? 'ANY'; + + // Skip HEAD-only routes + if ($method === 'HEAD' && count($methods) === 1) { + return null; + } + + // Prefer GET over HEAD when both present + if ($method === 'HEAD' && in_array('GET', $methods)) { + $method = 'GET'; + } + + $middleware = $route->gatherMiddleware(); + + return [ + 'method' => $method, + 'methods' => array_filter($methods, fn ($m) => $m !== 'HEAD'), + 'uri' => '/'.ltrim($route->uri(), '/'), + 'name' => $route->getName(), + 'action' => $this->formatAction($route->getActionName()), + 'action_full' => $route->getActionName(), + 'middleware' => $middleware, + 'middleware_string' => implode(', ', $middleware), + 'parameters' => $this->extractRouteParameters($route), + 'domain' => $route->getDomain(), + 'is_api' => $this->isApiRoute($route), + 'is_destructive' => in_array($method, self::DESTRUCTIVE_METHODS), + 'is_authenticated' => $this->requiresAuthentication($middleware), + 'has_csrf' => $this->requiresCsrf($middleware, $method), + ]; + } + + /** + * Extract parameters from a route. + * + * @return array + */ + public function extractRouteParameters(Route $route): array + { + $parameters = []; + $parameterNames = $route->parameterNames(); + $patterns = $route->wheres ?? []; + $uri = $route->uri(); + + foreach ($parameterNames as $name) { + // Check if parameter is optional (wrapped in {?param}) + $isOptional = preg_match('/\{'.preg_quote($name, '/').'?\??\}/', $uri, $matches) + && str_contains($matches[0] ?? '', '?'); + + $parameters[] = [ + 'name' => $name, + 'required' => ! $isOptional, + 'pattern' => $patterns[$name] ?? null, + ]; + } + + return $parameters; + } + + /** + * Build a test request for a route. + */ + public function buildTestRequest( + Route $route, + string $method = 'GET', + array $parameters = [], + array $queryParams = [], + array $bodyParams = [], + array $headers = [], + ): Request { + // Build URI with parameters + $uri = $this->buildUri($route, $parameters, $queryParams); + + // Merge headers + $allHeaders = array_merge($this->defaultHeaders, $headers); + + // Create request + $request = Request::create( + uri: $uri, + method: $method, + parameters: $bodyParams, + server: $this->convertHeadersToServer($allHeaders), + ); + + // Add JSON content type for body requests + if (! empty($bodyParams) && in_array($method, ['POST', 'PUT', 'PATCH'])) { + $request->headers->set('Content-Type', 'application/json'); + } + + return $request; + } + + /** + * Build URI with parameters substituted. + */ + public function buildUri(Route $route, array $parameters = [], array $queryParams = []): string + { + $uri = '/'.ltrim($route->uri(), '/'); + + // Replace route parameters + foreach ($parameters as $name => $value) { + $uri = preg_replace('/\{'.preg_quote($name, '/').'(\?)?\}/', (string) $value, $uri); + } + + // Remove any remaining optional parameters + $uri = preg_replace('/\{[^}]+\?\}/', '', $uri); + + // Add query string + if (! empty($queryParams)) { + $uri .= '?'.http_build_query($queryParams); + } + + return $uri; + } + + /** + * Execute a test request against a route. + */ + public function executeRequest( + Route $route, + string $method = 'GET', + array $parameters = [], + array $queryParams = [], + array $bodyParams = [], + array $headers = [], + ?object $authenticatedUser = null, + ): RouteTestResult { + if (! $this->isTestingAllowed()) { + return new RouteTestResult( + statusCode: 403, + headers: [], + body: 'Route testing is only allowed in local/testing environments.', + responseTime: 0, + memoryUsage: 0, + method: $method, + uri: $this->buildUri($route, $parameters, $queryParams), + ); + } + + $startTime = microtime(true); + $startMemory = memory_get_usage(); + $uri = $this->buildUri($route, $parameters, $queryParams); + + try { + // Build request + $request = $this->buildTestRequest( + $route, + $method, + $parameters, + $queryParams, + $bodyParams, + $headers, + ); + + // Set authenticated user if provided + if ($authenticatedUser) { + $request->setUserResolver(fn () => $authenticatedUser); + } + + // Handle the request through the kernel + $kernel = app(\Illuminate\Contracts\Http\Kernel::class); + $response = $kernel->handle($request); + + $endTime = microtime(true); + $endMemory = memory_get_usage(); + + // Extract response details + $responseHeaders = []; + foreach ($response->headers->all() as $name => $values) { + $responseHeaders[$name] = implode(', ', $values); + } + + return new RouteTestResult( + statusCode: $response->getStatusCode(), + headers: $responseHeaders, + body: $response->getContent(), + responseTime: ($endTime - $startTime) * 1000, + memoryUsage: max(0, $endMemory - $startMemory), + method: $method, + uri: $uri, + ); + } catch (Throwable $e) { + $endTime = microtime(true); + $endMemory = memory_get_usage(); + + return new RouteTestResult( + statusCode: 500, + headers: [], + body: $e->getMessage(), + responseTime: ($endTime - $startTime) * 1000, + memoryUsage: max(0, $endMemory - $startMemory), + exception: $e, + method: $method, + uri: $uri, + ); + } + } + + /** + * Format the response for display. + */ + public function formatResponse(RouteTestResult $result): array + { + return $result->toArray(); + } + + /** + * Get method colour class. + */ + public function getMethodColour(string $method): string + { + return match (strtoupper($method)) { + 'GET' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'POST' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'PUT' => 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'PATCH' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + 'DELETE' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + 'OPTIONS' => 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400', + default => 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + }; + } + + /** + * Get status code colour class. + */ + public function getStatusColour(int $statusCode): string + { + return match (true) { + $statusCode >= 200 && $statusCode < 300 => 'text-green-600 dark:text-green-400', + $statusCode >= 300 && $statusCode < 400 => 'text-blue-600 dark:text-blue-400', + $statusCode >= 400 && $statusCode < 500 => 'text-amber-600 dark:text-amber-400', + $statusCode >= 500 => 'text-red-600 dark:text-red-400', + default => 'text-zinc-600 dark:text-zinc-400', + }; + } + + /** + * Format action name for display. + */ + protected function formatAction(string $action): string + { + // Shorten common prefixes + $action = str_replace('App\\Http\\Controllers\\', '', $action); + $action = str_replace('App\\Mod\\', 'Mod\\', $action); + + return $action; + } + + /** + * Check if route is an API route. + */ + protected function isApiRoute(Route $route): bool + { + $uri = $route->uri(); + $middleware = $route->gatherMiddleware(); + + return str_starts_with($uri, 'api/') + || in_array('api', $middleware) + || str_contains(implode(',', $middleware), 'api'); + } + + /** + * Check if route requires authentication. + */ + protected function requiresAuthentication(array $middleware): bool + { + $authMiddleware = ['auth', 'auth:sanctum', 'auth:api', 'auth:web']; + + foreach ($middleware as $m) { + if (in_array($m, $authMiddleware) || str_starts_with($m, 'auth:')) { + return true; + } + } + + return false; + } + + /** + * Check if route requires CSRF protection. + */ + protected function requiresCsrf(array $middleware, string $method): bool + { + // Only non-GET methods need CSRF + if (in_array($method, ['GET', 'HEAD', 'OPTIONS'])) { + return false; + } + + // Check for web middleware (which includes CSRF) + return in_array('web', $middleware) + || in_array('VerifyCsrfToken', $middleware); + } + + /** + * Convert headers array to server format. + */ + protected function convertHeadersToServer(array $headers): array + { + $server = []; + + foreach ($headers as $name => $value) { + $key = 'HTTP_'.strtoupper(str_replace('-', '_', $name)); + $server[$key] = $value; + } + + return $server; + } +} diff --git a/app/Mod/Developer/Tests/UseCase/DevToolsBasic.php b/app/Mod/Developer/Tests/UseCase/DevToolsBasic.php new file mode 100644 index 0000000..9c93a26 --- /dev/null +++ b/app/Mod/Developer/Tests/UseCase/DevToolsBasic.php @@ -0,0 +1,82 @@ +user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + }); + + it('can view the logs page with all sections', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.dev.logs')); + + $response->assertOk(); + + // Verify page title + $response->assertSee(__('developer::developer.logs.title')); + + // Verify action buttons + $response->assertSee(__('developer::developer.logs.actions.refresh')); + $response->assertSee(__('developer::developer.logs.actions.clear')); + + // Verify level filters + $response->assertSee(__('developer::developer.logs.levels.error')); + $response->assertSee(__('developer::developer.logs.levels.warning')); + $response->assertSee(__('developer::developer.logs.levels.info')); + $response->assertSee(__('developer::developer.logs.levels.debug')); + }); + + it('can view the routes page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.dev.routes')); + + $response->assertOk(); + + // Verify page title + $response->assertSee(__('developer::developer.routes.title')); + + // Verify table headers + $response->assertSee(__('developer::developer.routes.table.method')); + $response->assertSee(__('developer::developer.routes.table.uri')); + $response->assertSee(__('developer::developer.routes.table.name')); + $response->assertSee(__('developer::developer.routes.table.action')); + }); + + it('can view the cache page', function () { + $this->actingAs($this->user); + + $response = $this->get(route('hub.dev.cache')); + + $response->assertOk(); + + // Verify page title + $response->assertSee(__('developer::developer.cache.title')); + + // Verify cache actions + $response->assertSee(__('developer::developer.cache.cards.application.title')); + $response->assertSee(__('developer::developer.cache.cards.config.title')); + $response->assertSee(__('developer::developer.cache.cards.view.title')); + $response->assertSee(__('developer::developer.cache.cards.route.title')); + }); +}); diff --git a/app/Mod/Developer/View/Blade/admin/activity-log.blade.php b/app/Mod/Developer/View/Blade/admin/activity-log.blade.php new file mode 100644 index 0000000..373e1fb --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/activity-log.blade.php @@ -0,0 +1,115 @@ +{{-- +Activity Log Viewer. + +Shows activity logs from Spatie activity log package. +--}} + +
+ {{-- Header --}} +
+ {{ __('Activity Log') }} +
+ + {{-- Filters --}} +
+
+ +
+ + All types + @foreach ($this->subjectTypes as $type) + {{ $type }} + @endforeach + + + All events + @foreach ($this->events as $event) + {{ ucfirst($event) }} + @endforeach + + @if($searchTerm || $filterSubjectType || $filterEvent) + Clear + @endif +
+ + {{-- Activity table --}} + + + Time + Event + Subject + Description + User + Changes + + + + @forelse ($this->activities as $activity) + + + {{ $activity->created_at->format('M j, Y H:i:s') }} + + + + {{ ucfirst($activity->event ?? 'unknown') }} + + + +
{{ class_basename($activity->subject_type ?? '') }}
+ @if($activity->subject_id) +
ID: {{ $activity->subject_id }}
+ @endif +
+ + {{ $activity->description }} + + + @if($activity->causer) + {{ $activity->causer->name ?? $activity->causer->email ?? 'System' }} + @else + System + @endif + + + @if($activity->properties && $activity->properties->count()) + + View + + @else + + @endif + +
+ @empty + + +
+
+ +
+ No activity found + Activity will appear here as users interact with the system. +
+
+
+ @endforelse +
+
+ + @if($this->activities->hasPages()) +
+ {{ $this->activities->links() }} +
+ @endif +
diff --git a/app/Mod/Developer/View/Blade/admin/cache.blade.php b/app/Mod/Developer/View/Blade/admin/cache.blade.php new file mode 100644 index 0000000..f749b7b --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/cache.blade.php @@ -0,0 +1,134 @@ +{{-- +Cache Management. + +Provides controls for clearing various Laravel caches. +--}} + +
+ {{-- Header --}} +
+ {{ __('developer::developer.cache.title') }} +
+ + {{-- Cache actions --}} +
+ {{-- Application cache --}} +
+
+
+ +
+
+

{{ __('developer::developer.cache.cards.application.title') }}

+

{{ __('developer::developer.cache.cards.application.description') }}

+
+
+ + {{ __('developer::developer.cache.cards.application.action') }} + +
+ + {{-- Config cache --}} +
+
+
+ +
+
+

{{ __('developer::developer.cache.cards.config.title') }}

+

{{ __('developer::developer.cache.cards.config.description') }}

+
+
+ + {{ __('developer::developer.cache.cards.config.action') }} + +
+ + {{-- View cache --}} +
+
+
+ +
+
+

{{ __('developer::developer.cache.cards.view.title') }}

+

{{ __('developer::developer.cache.cards.view.description') }}

+
+
+ + {{ __('developer::developer.cache.cards.view.action') }} + +
+ + {{-- Route cache --}} +
+
+
+ +
+
+

{{ __('developer::developer.cache.cards.route.title') }}

+

{{ __('developer::developer.cache.cards.route.description') }}

+
+
+ + {{ __('developer::developer.cache.cards.route.action') }} + +
+ + {{-- Clear all --}} +
+
+
+ +
+
+

{{ __('developer::developer.cache.cards.all.title') }}

+

{{ __('developer::developer.cache.cards.all.description') }}

+
+
+ + {{ __('developer::developer.cache.cards.all.action') }} + +
+ + {{-- Optimise --}} +
+
+
+ +
+
+

{{ __('developer::developer.cache.cards.optimise.title') }}

+

{{ __('developer::developer.cache.cards.optimise.description') }}

+
+
+ + {{ __('developer::developer.cache.cards.optimise.action') }} + +
+
+ + {{-- Last action output --}} + @if($lastOutput) +
+
+ + {{ __('developer::developer.cache.last_action') }}: {{ $lastAction }} +
+
{{ $lastOutput }}
+
+ @endif + + {{-- Confirmation modal --}} + +
+ Confirm Action +

{{ $confirmationMessage }}

+
+ Cancel + Confirm +
+
+
+
diff --git a/app/Mod/Developer/View/Blade/admin/database.blade.php b/app/Mod/Developer/View/Blade/admin/database.blade.php new file mode 100644 index 0000000..e69f132 --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/database.blade.php @@ -0,0 +1,134 @@ +{{-- +Database Query Tool. + +Execute read-only SQL queries against the application database. +--}} + +
+ {{-- Header --}} +
+ Database Query +
+ + {{-- Warning banner --}} +
+
+ +
+

Read-only access

+

Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are permitted. Results limited to {{ $maxRows }} rows.

+
+
+
+ + {{-- Connection info --}} +
+
+ + Database: {{ $this->connectionInfo['database'] }} +
+
+ + Driver: {{ $this->connectionInfo['driver'] }} +
+
+ + {{-- Query input --}} +
+ +
+ + Execute + Executing... + + + Clear + +
+
+ + {{-- Error display --}} + @if($error) +
+
+ +
+

Query failed

+

{{ $error }}

+
+
+
+ @endif + + {{-- Results --}} + @if(!empty($results)) +
+ {{-- Result stats --}} +
+
+ + @if($rowCount > count($results)) + Showing {{ count($results) }} of {{ number_format($rowCount) }} rows (limited) + @else + {{ number_format($rowCount) }} {{ Str::plural('row', $rowCount) }} + @endif + + {{ $executionTime }}ms +
+ {{ count($columns) }} {{ Str::plural('column', count($columns)) }} +
+ + {{-- Results table --}} +
+ + + @foreach($columns as $column) + {{ $column }} + @endforeach + + + + @foreach($results as $row) + + @foreach($columns as $column) + + @if(is_null($row[$column])) + NULL + @elseif(is_bool($row[$column])) + {{ $row[$column] ? 'true' : 'false' }} + @elseif(is_numeric($row[$column])) + {{ $row[$column] }} + @else + {{ Str::limit((string) $row[$column], 100) }} + @endif + + @endforeach + + @endforeach + + +
+
+ @elseif(!$error && $query && !$processing && $rowCount === 0 && !empty($columns)) + {{-- Empty result set --}} +
+ +

Query returned no results.

+
+ @endif +
diff --git a/app/Mod/Developer/View/Blade/admin/logs.blade.php b/app/Mod/Developer/View/Blade/admin/logs.blade.php new file mode 100644 index 0000000..932ee4a --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/logs.blade.php @@ -0,0 +1,95 @@ +{{-- +Application Logs viewer. + +Displays recent Laravel log entries with filtering by level. +--}} + +
+ {{-- Header --}} +
+ {{ __('developer::developer.logs.title') }} +
+ + + {{ __('developer::developer.logs.actions.refresh') }} + + + + {{ __('developer::developer.logs.actions.download') }} + + + + {{ __('developer::developer.logs.actions.clear') }} + +
+
+ + {{-- Level filters --}} +
+ @foreach(['error', 'warning', 'info', 'debug'] as $level) + @php + $levelColors = [ + 'error' => 'red', + 'warning' => 'amber', + 'info' => 'blue', + 'debug' => 'zinc', + ]; + $color = $levelColors[$level] ?? 'zinc'; + @endphp + + {{ __('developer::developer.logs.levels.' . $level) }} + + @endforeach + + @if($levelFilter) + + + {{ __('developer::developer.logs.clear_filter') }} + + @endif +
+ + {{-- Logs list --}} +
+ @forelse($logs as $log) + @php + $levelColors = [ + 'error' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + 'warning' => 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'info' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'debug' => 'bg-zinc-100 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300', + ]; + $color = $levelColors[$log['level']] ?? 'bg-zinc-100 text-zinc-700'; + @endphp +
+ {{-- Level badge --}} +
+ + {{ $log['level'] }} + +
+ + {{-- Message --}} +
+

+ {{ $log['message'] }} +

+

+ {{ $log['time'] }} +

+
+
+ @empty +
+ +

+ {{ __('developer::developer.logs.empty') }} +

+
+ @endforelse +
+
diff --git a/app/Mod/Developer/View/Blade/admin/route-inspector.blade.php b/app/Mod/Developer/View/Blade/admin/route-inspector.blade.php new file mode 100644 index 0000000..5f5ef89 --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/route-inspector.blade.php @@ -0,0 +1,466 @@ +{{-- +Route Inspector - Interactive route testing tool. + +Test routes with custom parameters, headers, and body content. +Only available in local/testing environments. +--}} + +
+ {{-- Header --}} +
+
+ {{ __('developer::developer.route_inspector.title') }} +

+ {{ __('developer::developer.route_inspector.description') }} +

+
+
+ {{ count($this->filteredRoutes) }} {{ Str::plural('route', count($this->filteredRoutes)) }} +
+
+ + {{-- Environment warning --}} + @unless($this->testingAllowed) + + Testing disabled + + Route testing is only available in local and testing environments for security reasons. + + + @endunless + +
+ {{-- Routes list (left panel) --}} +
+ {{-- Filters --}} +
+ {{-- Search --}} +
+ +
+ + {{-- Method filter --}} +
+ @foreach(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method) + + @endforeach +
+ + @if($search || $methodFilter) + + Clear + + @endif +
+ + {{-- Routes table --}} +
+
+ + + + + + + + + + + @forelse($this->filteredRoutes as $route) + + + + + + + @empty + + + + @endforelse + +
+ Method + + URI + + Name + + Actions +
+ + {{ $route['method'] }} + + @if($route['is_authenticated']) + + @endif + + {{ $route['uri'] }} + @if(count($route['parameters'] ?? []) > 0) + + ({{ count($route['parameters']) }} {{ Str::plural('param', count($route['parameters'])) }}) + + @endif + + {{ $route['name'] ?? '-' }} + +
+ @if($route['method'] === 'GET' && empty($route['parameters'])) + + Test + + @endif + + Inspect + +
+
+ +

+ No routes found matching your criteria. +

+
+
+
+
+ + {{-- History panel (right sidebar) --}} +
+
+
+

Recent Tests

+ @if(count($history) > 0) + + Clear + + @endif +
+ + @if(count($history) > 0) +
+ @foreach($history as $index => $item) + + @endforeach +
+ @else +

+ No tests run yet. Click on a route to inspect and test it. +

+ @endif +
+
+
+ + {{-- Inspector modal/drawer --}} + + @if($selectedRoute) +
+ {{-- Route header --}} +
+
+
+ + {{ $selectedRoute['method'] }} + + {{ $selectedRoute['uri'] }} +
+ @if($selectedRoute['name']) +

+ Name: {{ $selectedRoute['name'] }} +

+ @endif +

+ Action: + {{ $selectedRoute['action'] }} +

+
+ + @if($selectedRoute['is_destructive']) + + Destructive + + @endif +
+ + {{-- Route details --}} +
+ @if($selectedRoute['middleware_string']) +
+ Middleware: + {{ $selectedRoute['middleware_string'] }} +
+ @endif + + @if(count($selectedRoute['methods'] ?? []) > 1) +
+ Methods: +
+ @foreach($selectedRoute['methods'] as $method) + + @endforeach +
+
+ @endif +
+ + {{-- Destructive warning --}} + @if(in_array($selectedMethod, ['DELETE', 'PUT', 'PATCH', 'POST'])) + + + This is a {{ $selectedMethod }} request. It may modify data in your local database. + + + @endif + + {{-- Request builder --}} +
+

Request Builder

+ + {{-- Route parameters --}} + @if(count($selectedRoute['parameters'] ?? []) > 0) +
+ + @foreach($selectedRoute['parameters'] as $param) + + @endforeach +
+ @endif + + {{-- Query parameters --}} +
+
+ + + Add + +
+ @foreach($queryParams as $index => $param) +
+ + + +
+ @endforeach +
+ + {{-- Body (for POST/PUT/PATCH) --}} + @if(in_array($selectedMethod, ['POST', 'PUT', 'PATCH'])) +
+ + +
+ @endif + + {{-- Custom headers --}} +
+ + +
+ + {{-- Authentication option --}} +
+ + @if($selectedRoute['is_authenticated']) + + Route requires auth + + @endif +
+
+ + {{-- Execute button --}} +
+ + Execute Request + Executing... + + + + Copy as cURL + +
+ + {{-- Response panel --}} + @if($lastResult) +
+
+

Response

+
+ + {{ $lastResult['status_code'] }} {{ $lastResult['status_text'] }} + + @if(isset($lastResult['response_time_formatted'])) + {{ $lastResult['response_time_formatted'] }} + @endif + @if(isset($lastResult['memory_usage_formatted'])) + {{ $lastResult['memory_usage_formatted'] }} + @endif +
+
+ + {{-- Exception display --}} + @if(isset($lastResult['exception']) && $lastResult['exception']) + + {{ $lastResult['exception']['class'] }} + + {{ $lastResult['exception']['message'] }} +
+ {{ $lastResult['exception']['file'] }}:{{ $lastResult['exception']['line'] }} +
+
+ @endif + + {{-- Response headers --}} + @if(isset($lastResult['headers']) && count($lastResult['headers']) > 0) +
+ + Headers ({{ count($lastResult['headers']) }}) + +
+ @foreach($lastResult['headers'] as $name => $value) +
+ {{ $name }}: + {{ Str::limit($value, 100) }} +
+ @endforeach +
+
+ @endif + + {{-- Response body --}} +
+
+ + Body + @if(isset($lastResult['body_length'])) + ({{ number_format($lastResult['body_length']) }} bytes) + @endif + + + Copy + +
+
+
{{ Str::limit($lastResult['body'] ?? '', 10000) }}
+
+
+
+ @endif + + {{-- Footer --}} +
+ + Close + +
+
+ @endif +
+
+ +@script + +@endscript diff --git a/app/Mod/Developer/View/Blade/admin/routes.blade.php b/app/Mod/Developer/View/Blade/admin/routes.blade.php new file mode 100644 index 0000000..adc2081 --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/routes.blade.php @@ -0,0 +1,145 @@ +{{-- +Application Routes viewer. + +Displays all registered Laravel routes with filtering. +Click on a route to open it in the Route Inspector for testing. +--}} + +
+ {{-- Header --}} +
+ {{ __('developer::developer.routes.title') }} +
+
+ {{ __('developer::developer.routes.count', ['count' => count($this->filteredRoutes)]) }} +
+ + Route Inspector + +
+
+ + {{-- Filters --}} +
+ {{-- Search --}} +
+ +
+ + {{-- Method filter --}} +
+ @foreach(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method) + @php + $methodColors = [ + 'GET' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'POST' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'PUT' => 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'PATCH' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + 'DELETE' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + ]; + @endphp + + @endforeach +
+ + @if($search || $methodFilter) + + + {{ __('developer::developer.routes.clear') }} + + @endif +
+ + {{-- Routes table --}} +
+ + + + + + + + + + + + @forelse($this->filteredRoutes as $route) + @php + $methodColors = [ + 'GET' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + 'POST' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + 'PUT' => 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + 'PATCH' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + 'DELETE' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + 'ANY' => 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + ]; + $color = $methodColors[$route['method']] ?? 'bg-zinc-100 text-zinc-700'; + $inspectorUrl = route('hub.dev.route-inspector', [ + 'search' => $route['uri'], + 'methodFilter' => $route['method'], + ]); + @endphp + + + + + + + + @empty + + + + @endforelse + +
+ {{ __('developer::developer.routes.table.method') }} + + {{ __('developer::developer.routes.table.uri') }} + + {{ __('developer::developer.routes.table.name') }} + + {{ __('developer::developer.routes.table.action') }} + + Test +
+ + {{ $route['method'] }} + + + + {{ $route['uri'] }} + + + {{ $route['name'] ?? '-' }} + + {{ $route['action'] }} + + + + Test + +
+ +

+ {{ __('developer::developer.routes.empty') }} +

+
+
+
diff --git a/app/Mod/Developer/View/Blade/admin/servers.blade.php b/app/Mod/Developer/View/Blade/admin/servers.blade.php new file mode 100644 index 0000000..2247ae0 --- /dev/null +++ b/app/Mod/Developer/View/Blade/admin/servers.blade.php @@ -0,0 +1,221 @@ +{{-- +Server Management. + +CRUD interface for managing SSH server connections. +--}} + +
+ {{-- Header --}} +
+ Server Management + + Add Server + +
+ + {{-- Test result notification --}} + @if($testResult) + + {{ $testResult }} + + @endif + + {{-- Servers table --}} + + + Name + Connection + Status + Last Connected + Actions + + + + @forelse ($this->servers as $server) + + +
{{ $server->name }}
+
+ + + {{ $server->connection_string }} + + + + + {{ ucfirst($server->status) }} + + + + {{ $server->last_connected_at?->diffForHumans() ?? 'Never' }} + + +
+ + Test + Testing... + + + Edit + + + Delete + +
+
+
+ @empty + + +
+
+ +
+ No servers configured + Add your first server to get started with remote management. + + Add Server + +
+
+
+ @endforelse +
+
+ + {{-- Create/Edit modal --}} + +
+ + {{ $editingServerId ? 'Edit Server' : 'Add Server' }} + + +
+ {{-- Server name --}} +
+ Server Name + + @error('name') +

{{ $message }}

+ @enderror +
+ + {{-- IP Address --}} +
+ IP Address or Hostname + + @error('ip') +

{{ $message }}

+ @enderror +
+ + {{-- Port and User --}} +
+
+ SSH Port + + @error('port') +

{{ $message }}

+ @enderror +
+
+ SSH User + + @error('user') +

{{ $message }}

+ @enderror +
+
+ + {{-- Private Key --}} +
+ + Private Key + @if($editingServerId) + (leave empty to keep existing) + @endif + + + @error('private_key') +

{{ $message }}

+ @enderror +

+ The private key is encrypted at rest and never displayed after saving. +

+
+
+ +
+ + Cancel + + + {{ $editingServerId ? 'Update Server' : 'Add Server' }} + +
+
+
+ + {{-- Delete confirmation modal --}} + +
+ Delete Server +

+ Are you sure you want to delete this server? This action cannot be undone. +

+
+ Cancel + Delete Server +
+
+
+
diff --git a/app/Mod/Developer/View/Blade/vendor/pulse/dashboard.blade.php b/app/Mod/Developer/View/Blade/vendor/pulse/dashboard.blade.php new file mode 100644 index 0000000..6a95bb1 --- /dev/null +++ b/app/Mod/Developer/View/Blade/vendor/pulse/dashboard.blade.php @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/Mod/Developer/View/Modal/Admin/ActivityLog.php b/app/Mod/Developer/View/Modal/Admin/ActivityLog.php new file mode 100644 index 0000000..6b154e1 --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/ActivityLog.php @@ -0,0 +1,122 @@ +resetPage(); + } + + public function updatingFilterSubjectType(): void + { + $this->resetPage(); + } + + public function updatingFilterEvent(): void + { + $this->resetPage(); + } + + public function mount(): void + { + $this->checkHadesAccess(); + } + + #[Computed] + public function activities() + { + $query = Activity::query() + ->with('causer') + ->latest(); + + if ($this->searchTerm) { + $query->where(function ($q) { + $q->where('description', 'like', '%'.$this->searchTerm.'%') + ->orWhere('subject_type', 'like', '%'.$this->searchTerm.'%'); + }); + } + + if ($this->filterSubjectType) { + $query->where('subject_type', $this->filterSubjectType); + } + + if ($this->filterEvent) { + $query->where('event', $this->filterEvent); + } + + return $query->paginate(50); + } + + #[Computed] + public function subjectTypes(): array + { + return Activity::query() + ->select('subject_type') + ->distinct() + ->whereNotNull('subject_type') + ->pluck('subject_type') + ->map(fn ($type) => class_basename($type)) + ->sort() + ->values() + ->toArray(); + } + + #[Computed] + public function events(): array + { + return Activity::query() + ->select('event') + ->distinct() + ->whereNotNull('event') + ->pluck('event') + ->sort() + ->values() + ->toArray(); + } + + public function clearFilters(): void + { + $this->searchTerm = ''; + $this->filterSubjectType = ''; + $this->filterEvent = ''; + $this->resetPage(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.activity-log'); + } +} diff --git a/app/Mod/Developer/View/Modal/Admin/Cache.php b/app/Mod/Developer/View/Modal/Admin/Cache.php new file mode 100644 index 0000000..8d8dbc0 --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/Cache.php @@ -0,0 +1,142 @@ +checkHadesAccess(); + } + + public function requestConfirmation(string $action): void + { + $this->pendingAction = $action; + $this->confirmationMessage = match ($action) { + 'cache' => 'This will clear the application cache. Active sessions and cached data will be lost.', + 'config' => 'This will clear the configuration cache. The app will re-read all config files.', + 'view' => 'This will clear compiled Blade templates. Views will be recompiled on next request.', + 'route' => 'This will clear the route cache. Routes will be re-registered on next request.', + 'all' => 'This will clear ALL caches (application, config, views, routes). This may cause temporary slowdown.', + 'optimize' => 'This will cache config, routes, and views for production. Only use in production!', + default => 'Are you sure you want to proceed?', + }; + $this->showConfirmation = true; + } + + public function cancelAction(): void + { + $this->showConfirmation = false; + $this->pendingAction = ''; + $this->confirmationMessage = ''; + } + + public function confirmAction(): void + { + $action = $this->pendingAction; + $this->cancelAction(); + + match ($action) { + 'cache' => $this->clearCache(), + 'config' => $this->clearConfig(), + 'view' => $this->clearViews(), + 'route' => $this->clearRoutes(), + 'all' => $this->clearAll(), + 'optimize' => $this->optimise(), + default => null, + }; + } + + protected function clearCache(): void + { + Artisan::call('cache:clear'); + $this->lastAction = 'cache'; + $this->lastOutput = trim(Artisan::output()); + $this->dispatch('notify', message: 'Application cache cleared'); + } + + protected function clearConfig(): void + { + Artisan::call('config:clear'); + $this->lastAction = 'config'; + $this->lastOutput = trim(Artisan::output()); + $this->dispatch('notify', message: 'Configuration cache cleared'); + } + + protected function clearViews(): void + { + Artisan::call('view:clear'); + $this->lastAction = 'view'; + $this->lastOutput = trim(Artisan::output()); + $this->dispatch('notify', message: 'View cache cleared'); + } + + protected function clearRoutes(): void + { + Artisan::call('route:clear'); + $this->lastAction = 'route'; + $this->lastOutput = trim(Artisan::output()); + $this->dispatch('notify', message: 'Route cache cleared'); + } + + protected function clearAll(): void + { + $output = []; + + Artisan::call('cache:clear'); + $output[] = trim(Artisan::output()); + + Artisan::call('config:clear'); + $output[] = trim(Artisan::output()); + + Artisan::call('view:clear'); + $output[] = trim(Artisan::output()); + + Artisan::call('route:clear'); + $output[] = trim(Artisan::output()); + + $this->lastAction = 'all'; + $this->lastOutput = implode("\n", $output); + $this->dispatch('notify', message: 'All caches cleared'); + } + + protected function optimise(): void + { + Artisan::call('optimize'); + $this->lastAction = 'optimize'; + $this->lastOutput = trim(Artisan::output()); + $this->dispatch('notify', message: 'Application optimised'); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.cache'); + } +} diff --git a/app/Mod/Developer/View/Modal/Admin/Database.php b/app/Mod/Developer/View/Modal/Admin/Database.php new file mode 100644 index 0000000..d25c052 --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/Database.php @@ -0,0 +1,153 @@ +checkHadesAccess(); + } + + public function executeQuery(): void + { + $this->reset(['results', 'columns', 'error', 'rowCount', 'executionTime']); + $this->processing = true; + + $normalised = $this->normaliseQuery($this->query); + + if (empty($normalised)) { + $this->error = 'Please enter a SQL query.'; + $this->processing = false; + + return; + } + + if (! $this->isReadOnlyQuery($normalised)) { + $this->error = 'Only read-only queries are allowed (SELECT, SHOW, DESCRIBE, EXPLAIN).'; + $this->processing = false; + Log::warning('Database query tool: blocked non-read-only query', [ + 'user_id' => auth()->id(), + 'user_email' => auth()->user()?->email, + 'query' => $this->query, + 'ip' => request()->ip(), + ]); + + return; + } + + try { + $startTime = microtime(true); + + $results = DB::select($normalised); + + $this->executionTime = round((microtime(true) - $startTime) * 1000, 2); + + // Convert to array and limit results + $this->results = array_slice( + array_map(fn ($row) => (array) $row, $results), + 0, + self::MAX_ROWS + ); + + $this->rowCount = count($results); + + // Extract column names from first result + if (! empty($this->results)) { + $this->columns = array_keys($this->results[0]); + } + + Log::info('Database query executed', [ + 'user_id' => auth()->id(), + 'user_email' => auth()->user()?->email, + 'query' => $normalised, + 'row_count' => $this->rowCount, + 'execution_time_ms' => $this->executionTime, + 'ip' => request()->ip(), + ]); + } catch (\Exception $e) { + $this->error = $e->getMessage(); + Log::warning('Database query failed', [ + 'user_id' => auth()->id(), + 'user_email' => auth()->user()?->email, + 'query' => $normalised, + 'error' => $e->getMessage(), + 'ip' => request()->ip(), + ]); + } + + $this->processing = false; + } + + public function clearQuery(): void + { + $this->reset(['query', 'results', 'columns', 'error', 'rowCount', 'executionTime']); + } + + public function getConnectionInfoProperty(): array + { + $connection = DB::connection(); + + return [ + 'database' => $connection->getDatabaseName(), + 'driver' => $connection->getDriverName(), + ]; + } + + protected function normaliseQuery(string $query): string + { + // Trim whitespace and normalise + return trim(preg_replace('/\s+/', ' ', $query)); + } + + protected function isReadOnlyQuery(string $query): bool + { + // Get first word of query + $firstWord = strtoupper(strtok($query, ' ')); + + return in_array($firstWord, self::ALLOWED_STATEMENTS, true); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.database'); + } +} diff --git a/app/Mod/Developer/View/Modal/Admin/Logs.php b/app/Mod/Developer/View/Modal/Admin/Logs.php new file mode 100644 index 0000000..79ce815 --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/Logs.php @@ -0,0 +1,110 @@ +checkHadesAccess(); + $this->loadLogs(); + } + + public function loadLogs(): void + { + $logReader = app(LogReaderService::class); + $logFile = $logReader->getDefaultLogPath(); + + $levelFilter = $this->levelFilter ?: null; + $this->logs = $logReader->readLogEntries($logFile, maxLines: 500, levelFilter: $levelFilter); + + // Reverse to show most recent first and limit + $this->logs = array_slice(array_reverse($this->logs), 0, $this->limit); + } + + public function refresh(): void + { + $this->loadLogs(); + } + + public function setLevel(string $level): void + { + $this->levelFilter = $level === $this->levelFilter ? '' : $level; + $this->loadLogs(); + } + + public function clearLogs(): void + { + $logReader = app(LogReaderService::class); + $logFile = $logReader->getDefaultLogPath(); + $previousSize = $logReader->clearLogFile($logFile); + + if ($previousSize !== false) { + // Audit log the clear action + Log::info('Application logs cleared', [ + 'user_id' => auth()->id(), + 'user_email' => auth()->user()?->email, + 'previous_size_bytes' => $previousSize, + 'ip' => request()->ip(), + ]); + } + + $this->logs = []; + } + + public function downloadLogs() + { + $logReader = app(LogReaderService::class); + $logFile = $logReader->getCurrentLogPath(); + + if (! file_exists($logFile)) { + session()->flash('error', 'Log file not found.'); + + return; + } + + $filename = 'laravel-'.date('Y-m-d-His').'.log'; + + return response()->streamDownload(function () use ($logFile, $logReader) { + // Read the file and redact sensitive data before sending + $handle = fopen($logFile, 'r'); + if ($handle) { + while (($line = fgets($handle)) !== false) { + echo $logReader->redactSensitiveData($line); + } + fclose($handle); + } + }, $filename, [ + 'Content-Type' => 'text/plain', + ]); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.logs'); + } +} diff --git a/app/Mod/Developer/View/Modal/Admin/RouteInspector.php b/app/Mod/Developer/View/Modal/Admin/RouteInspector.php new file mode 100644 index 0000000..e5b27ea --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/RouteInspector.php @@ -0,0 +1,482 @@ +routeTestService = $routeTestService; + } + + public function mount(): void + { + $this->checkHadesAccess(); + + if (! $this->routeTestService->isTestingAllowed()) { + session()->flash('error', 'Route testing is only available in local/testing environments.'); + } + } + + #[Computed(cache: true)] + public function routes(): array + { + return $this->routeTestService->getRoutes(); + } + + #[Computed] + public function filteredRoutes(): array + { + return collect($this->routes) + ->filter(function ($route) { + // Method filter + if ($this->methodFilter && $route['method'] !== $this->methodFilter) { + return false; + } + + // Search filter + if ($this->search) { + $searchLower = strtolower($this->search); + + return str_contains(strtolower($route['uri']), $searchLower) + || str_contains(strtolower($route['name'] ?? ''), $searchLower) + || str_contains(strtolower($route['action']), $searchLower); + } + + return true; + }) + ->values() + ->toArray(); + } + + #[Computed] + public function testingAllowed(): bool + { + return $this->routeTestService->isTestingAllowed(); + } + + public function setMethod(string $method): void + { + $this->methodFilter = $method === $this->methodFilter ? '' : $method; + } + + public function clearFilters(): void + { + $this->search = ''; + $this->methodFilter = ''; + } + + /** + * Open the inspector for a specific route. + */ + public function inspectRoute(string $uri, string $method): void + { + $route = $this->routeTestService->findRoute($uri, $method); + + if (! $route) { + Flux::toast('Route not found', variant: 'danger'); + + return; + } + + $this->selectedRoute = $this->routeTestService->formatRoute($route); + $this->selectedMethod = $method; + $this->lastResult = null; + $this->bodyContent = ''; + $this->customHeaders = ''; + + // Initialise parameter inputs + $this->parameters = []; + foreach ($this->selectedRoute['parameters'] ?? [] as $param) { + $this->parameters[$param['name']] = ''; + } + + $this->queryParams = []; + $this->showInspector = true; + } + + /** + * Close the inspector panel. + */ + public function closeInspector(): void + { + $this->showInspector = false; + $this->selectedRoute = null; + $this->lastResult = null; + } + + /** + * Quick test a GET route (no modal). + */ + public function quickTest(string $uri, string $method = 'GET'): void + { + if (! $this->testingAllowed) { + Flux::toast('Route testing not allowed in this environment', variant: 'danger'); + + return; + } + + if ($method !== 'GET') { + // For non-GET, open the inspector instead + $this->inspectRoute($uri, $method); + + return; + } + + $route = $this->routeTestService->findRoute($uri, $method); + if (! $route) { + Flux::toast('Route not found', variant: 'danger'); + + return; + } + + // If route has required parameters, open inspector + $params = $this->routeTestService->extractRouteParameters($route); + $hasRequired = collect($params)->contains('required', true); + + if ($hasRequired) { + $this->inspectRoute($uri, $method); + + return; + } + + // Otherwise, execute directly + $this->selectedRoute = $this->routeTestService->formatRoute($route); + $this->selectedMethod = $method; + $this->executeTest(); + } + + /** + * Execute the test request. + */ + public function executeTest(): void + { + if (! $this->testingAllowed) { + Flux::toast('Route testing not allowed in this environment', variant: 'danger'); + + return; + } + + if (! $this->selectedRoute) { + return; + } + + $this->executing = true; + $this->lastResult = null; + + try { + $route = $this->routeTestService->findRoute( + $this->selectedRoute['uri'], + $this->selectedMethod + ); + + if (! $route) { + throw new \RuntimeException('Route not found'); + } + + // Parse custom headers + $headers = $this->parseHeaders($this->customHeaders); + + // Parse body content + $body = []; + if (! empty($this->bodyContent) && in_array($this->selectedMethod, ['POST', 'PUT', 'PATCH'])) { + $decoded = json_decode($this->bodyContent, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $body = $decoded; + } + } + + // Get authenticated user if requested + $user = $this->useAuthentication ? auth()->user() : null; + + // Log the test request + Log::info('Route test executed', [ + 'user_id' => auth()->id(), + 'route' => $this->selectedRoute['uri'], + 'method' => $this->selectedMethod, + 'ip' => request()->ip(), + ]); + + // Execute the request + $result = $this->routeTestService->executeRequest( + route: $route, + method: $this->selectedMethod, + parameters: array_filter($this->parameters), + queryParams: array_filter($this->queryParams), + bodyParams: $body, + headers: $headers, + authenticatedUser: $user, + ); + + $this->lastResult = $result->toArray(); + + // Add to history + $this->addToHistory($result); + + // Show appropriate toast + if ($result->isSuccessful()) { + Flux::toast("Request completed: {$result->statusCode} {$result->getStatusText()}", variant: 'success'); + } elseif ($result->hasException()) { + Flux::toast("Request failed: {$result->exception->getMessage()}", variant: 'danger'); + } else { + Flux::toast("Request completed: {$result->statusCode} {$result->getStatusText()}", variant: 'warning'); + } + } catch (\Throwable $e) { + $this->lastResult = [ + 'status_code' => 500, + 'status_text' => 'Internal Server Error', + 'body' => $e->getMessage(), + 'exception' => [ + 'class' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], + ]; + + Flux::toast("Error: {$e->getMessage()}", variant: 'danger'); + } + + $this->executing = false; + + // If not showing inspector, show it now with results + if (! $this->showInspector && $this->lastResult) { + $this->showInspector = true; + } + } + + /** + * Add query parameter field. + */ + public function addQueryParam(): void + { + $this->queryParams[] = ['key' => '', 'value' => '']; + } + + /** + * Remove query parameter field. + */ + public function removeQueryParam(int $index): void + { + unset($this->queryParams[$index]); + $this->queryParams = array_values($this->queryParams); + } + + /** + * Copy response to clipboard (via JavaScript). + */ + public function copyResponse(): void + { + $this->dispatch('copy-to-clipboard', content: $this->lastResult['body'] ?? ''); + } + + /** + * Copy as cURL command. + */ + public function copyAsCurl(): void + { + if (! $this->selectedRoute) { + return; + } + + $route = $this->routeTestService->findRoute( + $this->selectedRoute['uri'], + $this->selectedMethod + ); + + if (! $route) { + return; + } + + $url = config('app.url').$this->routeTestService->buildUri( + $route, + array_filter($this->parameters), + array_filter(collect($this->queryParams)->pluck('value', 'key')->toArray()) + ); + + $curl = "curl -X {$this->selectedMethod} '{$url}'"; + + // Add headers + $headers = $this->parseHeaders($this->customHeaders); + foreach ($headers as $name => $value) { + $curl .= " \\\n -H '{$name}: {$value}'"; + } + + // Add body + if (! empty($this->bodyContent)) { + $curl .= " \\\n -H 'Content-Type: application/json'"; + $curl .= " \\\n -d '".addslashes($this->bodyContent)."'"; + } + + $this->dispatch('copy-to-clipboard', content: $curl); + Flux::toast('cURL command copied to clipboard'); + } + + /** + * Clear test history. + */ + public function clearHistory(): void + { + $this->history = []; + Flux::toast('History cleared'); + } + + /** + * Load a previous test from history. + */ + public function loadFromHistory(int $index): void + { + if (! isset($this->history[$index])) { + return; + } + + $item = $this->history[$index]; + $this->inspectRoute($item['uri'], $item['method']); + + if (isset($item['parameters'])) { + $this->parameters = $item['parameters']; + } + + if (isset($item['query_params'])) { + $this->queryParams = $item['query_params']; + } + + if (isset($item['body'])) { + $this->bodyContent = $item['body']; + } + + if (isset($item['headers'])) { + $this->customHeaders = $item['headers']; + } + } + + /** + * Get colour class for HTTP method. + */ + public function getMethodColour(string $method): string + { + return $this->routeTestService->getMethodColour($method); + } + + /** + * Get colour class for status code. + */ + public function getStatusColour(int $statusCode): string + { + return $this->routeTestService->getStatusColour($statusCode); + } + + /** + * Parse header string into array. + */ + protected function parseHeaders(string $headerString): array + { + $headers = []; + + if (empty($headerString)) { + return $headers; + } + + $lines = explode("\n", $headerString); + foreach ($lines as $line) { + $line = trim($line); + if (empty($line) || ! str_contains($line, ':')) { + continue; + } + + [$name, $value] = explode(':', $line, 2); + $headers[trim($name)] = trim($value); + } + + return $headers; + } + + /** + * Add result to history. + */ + protected function addToHistory(RouteTestResult $result): void + { + array_unshift($this->history, [ + 'uri' => $result->uri, + 'method' => $result->method, + 'status_code' => $result->statusCode, + 'response_time' => $result->getFormattedResponseTime(), + 'timestamp' => now()->format('H:i:s'), + 'parameters' => $this->parameters, + 'query_params' => $this->queryParams, + 'body' => $this->bodyContent, + 'headers' => $this->customHeaders, + ]); + + // Keep only last 20 entries + $this->history = array_slice($this->history, 0, 20); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.route-inspector'); + } +} diff --git a/app/Mod/Developer/View/Modal/Admin/Routes.php b/app/Mod/Developer/View/Modal/Admin/Routes.php new file mode 100644 index 0000000..d1d4f2b --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/Routes.php @@ -0,0 +1,101 @@ +checkHadesAccess(); + $this->loadRoutes(); + } + + public function loadRoutes(): void + { + $this->routes = collect(Route::getRoutes())->map(function ($route) { + $methods = $route->methods(); + $method = $methods[0] ?? 'ANY'; + + if ($method === 'HEAD') { + return null; + } + + return [ + 'method' => $method, + 'uri' => '/'.ltrim($route->uri(), '/'), + 'name' => $route->getName(), + 'action' => $this->formatAction($route->getActionName()), + 'middleware' => implode(', ', $route->gatherMiddleware()), + ]; + })->filter()->values()->toArray(); + } + + private function formatAction(string $action): string + { + // Shorten controller names for readability + return str_replace('App\\Http\\Controllers\\', '', $action); + } + + public function updatedSearch(): void + { + // Filtering is done in the view + } + + public function setMethod(string $method): void + { + $this->methodFilter = $method === $this->methodFilter ? '' : $method; + } + + public function getFilteredRoutesProperty(): array + { + return collect($this->routes) + ->filter(function ($route) { + if ($this->methodFilter && $route['method'] !== $this->methodFilter) { + return false; + } + + if ($this->search) { + $searchLower = strtolower($this->search); + + return str_contains(strtolower($route['uri']), $searchLower) + || str_contains(strtolower($route['name'] ?? ''), $searchLower) + || str_contains(strtolower($route['action']), $searchLower); + } + + return true; + }) + ->values() + ->toArray(); + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.routes'); + } +} diff --git a/app/Mod/Developer/View/Modal/Admin/Servers.php b/app/Mod/Developer/View/Modal/Admin/Servers.php new file mode 100644 index 0000000..4302509 --- /dev/null +++ b/app/Mod/Developer/View/Modal/Admin/Servers.php @@ -0,0 +1,234 @@ +checkHadesAccess(); + } + + #[Computed] + public function servers() + { + return Server::ownedByCurrentWorkspace()->orderBy('name')->get(); + } + + public function openCreateModal(): void + { + $this->resetForm(); + $this->editingServerId = null; + $this->showEditModal = true; + } + + public function openEditModal(int $serverId): void + { + $server = Server::findOrFail($serverId); + + $this->editingServerId = $serverId; + $this->name = $server->name; + $this->ip = $server->ip; + $this->port = $server->port; + $this->user = $server->user; + $this->private_key = ''; // Never expose the key - leave empty to keep existing + + $this->showEditModal = true; + } + + public function closeEditModal(): void + { + $this->showEditModal = false; + $this->resetForm(); + } + + public function save(): void + { + $rules = [ + 'name' => 'required|string|max:255', + 'ip' => 'required|string|max:255', + 'port' => 'required|integer|min:1|max:65535', + 'user' => 'required|string|max:255', + ]; + + // Private key is required for new servers + if (! $this->editingServerId) { + $rules['private_key'] = 'required|string'; + } + + $this->validate($rules); + + $data = [ + 'name' => $this->name, + 'ip' => $this->ip, + 'port' => $this->port, + 'user' => $this->user, + 'status' => 'pending', + ]; + + // Only update private key if provided + if (! empty($this->private_key)) { + $data['private_key'] = $this->private_key; + } + + if ($this->editingServerId) { + $server = Server::findOrFail($this->editingServerId); + $server->update($data); + Flux::toast('Server updated successfully'); + } else { + Server::create($data); + Flux::toast('Server created successfully'); + } + + $this->closeEditModal(); + unset($this->servers); + } + + public function confirmDelete(int $serverId): void + { + $this->deletingServerId = $serverId; + $this->showDeleteConfirmation = true; + } + + public function cancelDelete(): void + { + $this->showDeleteConfirmation = false; + $this->deletingServerId = null; + } + + public function deleteServer(): void + { + if ($this->deletingServerId) { + Server::findOrFail($this->deletingServerId)->delete(); + Flux::toast('Server deleted'); + } + + $this->cancelDelete(); + unset($this->servers); + } + + public function testConnection(int $serverId): void + { + $this->testingServerId = $serverId; + $this->testResult = null; + $this->testSuccess = false; + + $server = Server::findOrFail($serverId); + + if (! $server->hasPrivateKey()) { + $this->testResult = 'No private key configured'; + $this->testSuccess = false; + $server->markAsFailed('No private key configured'); + unset($this->servers); + + return; + } + + try { + // Create a temporary key file + $tempKeyPath = sys_get_temp_dir().'/ssh_test_'.uniqid(); + file_put_contents($tempKeyPath, $server->getDecryptedPrivateKey()); + chmod($tempKeyPath, 0600); + + // Test SSH connection with a simple echo command + $result = Process::timeout(15)->run([ + 'ssh', + '-i', $tempKeyPath, + '-o', 'StrictHostKeyChecking=no', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=10', + '-p', (string) $server->port, + "{$server->user}@{$server->ip}", + 'echo "connected"', + ]); + + // Clean up temp key + @unlink($tempKeyPath); + + if ($result->successful() && str_contains($result->output(), 'connected')) { + $this->testResult = 'Connection successful'; + $this->testSuccess = true; + $server->update([ + 'status' => 'connected', + 'last_connected_at' => now(), + ]); + } else { + $errorOutput = $result->errorOutput() ?: $result->output(); + $this->testResult = 'Connection failed: '.($errorOutput ?: 'Unknown error'); + $this->testSuccess = false; + $server->markAsFailed($errorOutput ?: 'Connection failed'); + } + } catch (\Exception $e) { + $this->testResult = 'Connection failed: '.$e->getMessage(); + $this->testSuccess = false; + $server->markAsFailed($e->getMessage()); + } + + $this->testingServerId = null; + unset($this->servers); + } + + private function resetForm(): void + { + $this->name = ''; + $this->ip = ''; + $this->port = 22; + $this->user = 'root'; + $this->private_key = ''; + $this->editingServerId = null; + $this->testResult = null; + $this->testSuccess = false; + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render(): View + { + return view('developer::admin.servers'); + } +} diff --git a/composer.json b/composer.json index 76588f0..4c8f720 100644 --- a/composer.json +++ b/composer.json @@ -1,34 +1,22 @@ { - "name": "host-uk/core-template", - "type": "project", - "description": "Core PHP Framework - Project Template", - "keywords": ["laravel", "core-php", "modular", "framework", "template"], + "name": "host-uk/core-developer", + "type": "library", + "description": "Developer tools module for Core PHP Framework - route inspector, database explorer, logs viewer", + "keywords": ["laravel", "core-php", "developer-tools", "debugging", "route-inspector"], "license": "EUPL-1.2", "require": { "php": "^8.2", - "laravel/framework": "^12.0", - "laravel/tinker": "^2.10", - "livewire/flux": "^2.0", - "livewire/livewire": "^3.0", - "host-uk/core": "^1.0", - "host-uk/core-admin": "^1.0", - "host-uk/core-api": "^1.0", - "host-uk/core-mcp": "^1.0" + "laravel/framework": "^11.0|^12.0", + "livewire/livewire": "^3.0|^4.0", + "host-uk/core": "@dev", + "host-uk/core-admin": "@dev" }, "require-dev": { - "fakerphp/faker": "^1.23", - "laravel/pail": "^1.2", - "laravel/pint": "^1.18", - "laravel/sail": "^1.41", - "mockery/mockery": "^1.6", - "nunomaduro/collision": "^8.6", "phpunit/phpunit": "^11.5" }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Mod\\Developer\\": "app/Mod/Developer/" } }, "autoload-dev": { @@ -36,27 +24,11 @@ "Tests\\": "tests/" } }, - "repositories": [], - "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 -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", - "@php artisan migrate --graceful --ansi" - ] - }, "extra": { "laravel": { - "dont-discover": [] + "providers": [ + "Mod\\Developer\\Boot" + ] } }, "config": {