core-developer module

This commit is contained in:
Snider 2026-01-26 20:23:54 +00:00
parent c19612d751
commit 579d88b123
34 changed files with 5013 additions and 40 deletions

146
app/Mod/Developer/Boot.php Normal file
View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Mod\Developer;
use Core\Events\AdminPanelBooting;
use Core\Events\ConsoleBooting;
use Core\Front\Admin\AdminMenuRegistry;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class Boot extends ServiceProvider implements AdminMenuProvider
{
protected string $moduleName = 'developer';
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
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);
}
}

View file

@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Concerns;
use Mod\Developer\Exceptions\SshConnectionException;
use Mod\Developer\Models\Server;
use Core\Helpers\CommandResult;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
/**
* Trait for managing SSH connections to remote servers.
*
* Recommended usage with automatic cleanup:
* class DeployApplication implements ShouldQueue {
* use RemoteServerManager;
*
* public function handle(): void {
* $this->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<string> $commands
* @return array<CommandResult>
*
* @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;
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Mod\Developer\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class CopyDeviceFrames extends Command
{
protected $signature = 'device-frames:copy {--force : Overwrite existing files}';
protected $description = 'Copy device frame assets from source to public directory';
public function handle(): int
{
$config = config('device-frames');
$sourcePath = $config['source_path'];
$publicPath = public_path($config['public_path']);
if (! File::isDirectory($sourcePath)) {
$this->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("<comment>Skipping:</comment> {$deviceSlug}/{$variantSlug}.{$extension}");
$skipped++;
continue;
}
File::copy($sourceFile, $destFile);
$this->line("<info>Copied:</info> {$deviceSlug}/{$variantSlug}.{$extension}");
$copied++;
}
}
$this->newLine();
$this->info("Done! Copied: {$copied}, Skipped: {$skipped}, Failed: {$failed}");
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Mod\Developer\Controllers;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Mod\Developer\Services\LogReaderService;
class DevController extends Controller
{
public function __construct(
protected LogReaderService $logReader
) {}
/**
* Get recent log entries.
*/
public function logs(): JsonResponse
{
$logFile = $this->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,
]);
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Data;
use Illuminate\Http\Response;
use Throwable;
/**
* Result of testing a route.
*
* Contains the HTTP response details, performance metrics, and any exception
* that occurred during the request.
*/
readonly class RouteTestResult
{
/**
* @param int $statusCode HTTP status code
* @param array<string, string> $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,
];
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Exceptions;
use Exception;
/**
* Exception thrown when an SSH connection fails.
*/
class SshConnectionException extends Exception
{
public function __construct(
string $message = 'SSH connection failed.',
public readonly ?string $serverName = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* Get the server name that failed to connect.
*/
public function getServerName(): ?string
{
return $this->serverName;
}
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/**
* Developer module translations (en_GB).
*
* Key structure: section.subsection.key
*/
return [
// Application Logs
'logs' => [
'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.',
],
];

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Listeners;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Cookie;
class SetHadesCookie
{
/**
* Set the Hades debug cookie on successful login.
*
* This enables god-mode debug access for the user.
* The cookie contains an encrypted version of HADES_TOKEN.
* To revoke access, change HADES_TOKEN in the environment.
*/
public function handle(Login $event): void
{
$hadesToken = config('developer.hades_token');
if (empty($hadesToken)) {
return;
}
// Set encrypted cookie that lasts 1 year
// Cookie is HTTP-only and secure in production
Cookie::queue(Cookie::make(
name: 'hades',
value: encrypt($hadesToken),
minutes: 60 * 24 * 365, // 1 year
path: '/',
domain: config('session.domain'),
secure: config('app.env') === 'production',
httpOnly: true,
sameSite: 'lax'
));
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ApplyIconSettings
{
/**
* Read icon style and size preferences from cookies (set by JavaScript)
* and apply them to the session for use by the <x-icon> 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);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to require Hades (god-mode) access.
*
* Apply to routes that should only be accessible by users with Hades tier.
*
* Usage in routes:
* Route::middleware(['auth', 'hades'])->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);
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Developer module tables.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
// Servers for SSH connections
Schema::create('servers', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Server model for SSH connections.
*
* Stores server connection details with encrypted private key storage.
* Used with RemoteServerManager trait for remote command execution.
*
* @property int $id
* @property int $workspace_id
* @property string $name
* @property string $ip
* @property int $port
* @property string $user
* @property string|null $private_key
* @property string $status
* @property \Carbon\Carbon|null $last_connected_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property \Carbon\Carbon|null $deleted_at
*/
class Server extends Model
{
use BelongsToWorkspace;
use HasFactory;
use LogsActivity;
use SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'workspace_id',
'name',
'ip',
'port',
'user',
'private_key',
'status',
'last_connected_at',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'private_key',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'port' => 'integer',
'last_connected_at' => 'datetime',
'private_key' => 'encrypted',
];
/**
* Default attribute values.
*
* @var array<string, mixed>
*/
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');
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
$this->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;
});
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class TelescopeServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Skip if Telescope is not installed
if (! class_exists(\Laravel\Telescope\Telescope::class)) {
return;
}
$this->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;
});
}
}

View file

@ -0,0 +1,51 @@
<?php
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Developer Admin Routes
|--------------------------------------------------------------------------
*/
Route::prefix('hub')->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');
});

View file

@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Services;
/**
* Service for reading and parsing Laravel log files.
*
* Provides memory-efficient methods for reading large log files
* by reading from the end of the file rather than loading everything.
* Automatically redacts sensitive information from log output.
*/
class LogReaderService
{
/**
* Patterns to redact from log output.
* Keys are regex patterns, values are replacement text.
*/
protected const REDACTION_PATTERNS = [
// API keys and tokens (common formats)
'/\b(sk_live_|sk_test_|pk_live_|pk_test_)[a-zA-Z0-9]{20,}\b/' => '[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<string>
*/
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<array{time: string, level: string, message: string}>
*/
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<array{name: string, path: string, size: int, modified: int}>
*/
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;
}
}

View file

@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\Services;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route as RouteFacade;
use Mod\Developer\Data\RouteTestResult;
use Throwable;
/**
* Service for testing Laravel routes.
*
* Provides functionality to execute test requests against routes and format
* the responses for display in the developer tools.
*
* IMPORTANT: This service should only be used in local/development environments.
*/
class RouteTestService
{
/**
* Methods that can modify data - require extra confirmation.
*/
public const array DESTRUCTIVE_METHODS = ['DELETE', 'PATCH', 'PUT', 'POST'];
/**
* Methods that are safe to auto-test.
*/
public const array SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
/**
* Default headers for test requests.
*/
protected array $defaultHeaders = [
'Accept' => '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<array{
* method: string,
* uri: string,
* name: string|null,
* action: string,
* middleware: array,
* parameters: array,
* domain: string|null,
* is_api: bool,
* is_destructive: bool
* }>
*/
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<array{name: string, required: bool, pattern: string|null}>
*/
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;
}
}

View file

@ -0,0 +1,82 @@
<?php
/**
* UseCase: Developer Tools (Basic Flow)
*
* Acceptance test for the Developer admin panel.
* Tests the primary admin flow through the developer tools.
*/
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
describe('Developer Tools', function () {
beforeEach(function () {
// Create user with workspace
$this->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'));
});
});

View file

@ -0,0 +1,115 @@
{{--
Activity Log Viewer.
Shows activity logs from Spatie activity log package.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<core:heading size="xl">{{ __('Activity Log') }}</core:heading>
</div>
{{-- Filters --}}
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<flux:input wire:model.live.debounce.300ms="searchTerm" placeholder="Search activity..." icon="magnifying-glass" />
</div>
<flux:select wire:model.live="filterSubjectType" placeholder="All types">
<flux:select.option value="">All types</flux:select.option>
@foreach ($this->subjectTypes as $type)
<flux:select.option value="{{ $type }}">{{ $type }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model.live="filterEvent" placeholder="All events">
<flux:select.option value="">All events</flux:select.option>
@foreach ($this->events as $event)
<flux:select.option value="{{ $event }}">{{ ucfirst($event) }}</flux:select.option>
@endforeach
</flux:select>
@if($searchTerm || $filterSubjectType || $filterEvent)
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">Clear</flux:button>
@endif
</div>
{{-- Activity table --}}
<flux:table>
<flux:table.columns>
<flux:table.column>Time</flux:table.column>
<flux:table.column>Event</flux:table.column>
<flux:table.column>Subject</flux:table.column>
<flux:table.column>Description</flux:table.column>
<flux:table.column>User</flux:table.column>
<flux:table.column>Changes</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($this->activities as $activity)
<flux:table.row>
<flux:table.cell class="text-sm text-zinc-500">
{{ $activity->created_at->format('M j, Y H:i:s') }}
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="match($activity->event) {
'created' => 'green',
'updated' => 'blue',
'deleted' => 'red',
default => 'zinc'
}">
{{ ucfirst($activity->event ?? 'unknown') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
<div class="font-medium">{{ class_basename($activity->subject_type ?? '') }}</div>
@if($activity->subject_id)
<div class="text-xs text-zinc-500">ID: {{ $activity->subject_id }}</div>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm">
{{ $activity->description }}
</flux:table.cell>
<flux:table.cell class="text-sm">
@if($activity->causer)
{{ $activity->causer->name ?? $activity->causer->email ?? 'System' }}
@else
<span class="text-zinc-400">System</span>
@endif
</flux:table.cell>
<flux:table.cell>
@if($activity->properties && $activity->properties->count())
<flux:button
x-data
x-on:click="$dispatch('show-changes', { properties: {{ json_encode($activity->properties) }} })"
variant="ghost"
size="xs"
icon="eye"
>
View
</flux:button>
@else
<span class="text-zinc-400 text-xs"></span>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="flex flex-col items-center py-12">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="clock" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No activity found</flux:heading>
<flux:subheading class="mt-1">Activity will appear here as users interact with the system.</flux:subheading>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if($this->activities->hasPages())
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
{{ $this->activities->links() }}
</div>
@endif
</div>

View file

@ -0,0 +1,134 @@
{{--
Cache Management.
Provides controls for clearing various Laravel caches.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<core:heading size="xl">{{ __('developer::developer.cache.title') }}</core:heading>
</div>
{{-- Cache actions --}}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{-- Application cache --}}
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30">
<core:icon name="archive-box" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">{{ __('developer::developer.cache.cards.application.title') }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('developer::developer.cache.cards.application.description') }}</p>
</div>
</div>
<core:button wire:click="requestConfirmation('cache')" class="mt-4 w-full" variant="ghost">
{{ __('developer::developer.cache.cards.application.action') }}
</core:button>
</div>
{{-- Config cache --}}
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30">
<core:icon name="cog" class="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">{{ __('developer::developer.cache.cards.config.title') }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('developer::developer.cache.cards.config.description') }}</p>
</div>
</div>
<core:button wire:click="requestConfirmation('config')" class="mt-4 w-full" variant="ghost">
{{ __('developer::developer.cache.cards.config.action') }}
</core:button>
</div>
{{-- View cache --}}
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/30">
<core:icon name="eye" class="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">{{ __('developer::developer.cache.cards.view.title') }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('developer::developer.cache.cards.view.description') }}</p>
</div>
</div>
<core:button wire:click="requestConfirmation('view')" class="mt-4 w-full" variant="ghost">
{{ __('developer::developer.cache.cards.view.action') }}
</core:button>
</div>
{{-- Route cache --}}
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/30">
<core:icon name="map" class="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">{{ __('developer::developer.cache.cards.route.title') }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('developer::developer.cache.cards.route.description') }}</p>
</div>
</div>
<core:button wire:click="requestConfirmation('route')" class="mt-4 w-full" variant="ghost">
{{ __('developer::developer.cache.cards.route.action') }}
</core:button>
</div>
{{-- Clear all --}}
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
<core:icon name="trash" class="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">{{ __('developer::developer.cache.cards.all.title') }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('developer::developer.cache.cards.all.description') }}</p>
</div>
</div>
<core:button wire:click="requestConfirmation('all')" class="mt-4 w-full" variant="danger">
{{ __('developer::developer.cache.cards.all.action') }}
</core:button>
</div>
{{-- Optimise --}}
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
<core:icon name="bolt" class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h3 class="font-medium text-zinc-900 dark:text-white">{{ __('developer::developer.cache.cards.optimise.title') }}</h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('developer::developer.cache.cards.optimise.description') }}</p>
</div>
</div>
<core:button wire:click="requestConfirmation('optimize')" class="mt-4 w-full" variant="primary">
{{ __('developer::developer.cache.cards.optimise.action') }}
</core:button>
</div>
</div>
{{-- Last action output --}}
@if($lastOutput)
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<core:icon name="check-circle" class="h-4 w-4 text-green-500" />
{{ __('developer::developer.cache.last_action') }}: {{ $lastAction }}
</div>
<pre class="mt-2 text-xs text-zinc-600 dark:text-zinc-400 whitespace-pre-wrap">{{ $lastOutput }}</pre>
</div>
@endif
{{-- Confirmation modal --}}
<flux:modal wire:model="showConfirmation" class="max-w-md">
<div class="space-y-4">
<flux:heading size="lg">Confirm Action</flux:heading>
<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ $confirmationMessage }}</p>
<div class="flex justify-end gap-2">
<flux:button wire:click="cancelAction" variant="ghost">Cancel</flux:button>
<flux:button wire:click="confirmAction" variant="danger">Confirm</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,134 @@
{{--
Database Query Tool.
Execute read-only SQL queries against the application database.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<core:heading size="xl">Database Query</core:heading>
</div>
{{-- Warning banner --}}
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-900/20">
<div class="flex items-start gap-3">
<core:icon name="exclamation-triangle" class="h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<div class="text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium">Read-only access</p>
<p class="mt-1">Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries are permitted. Results limited to {{ $maxRows }} rows.</p>
</div>
</div>
</div>
{{-- Connection info --}}
<div class="flex flex-wrap items-center gap-4 text-sm text-zinc-600 dark:text-zinc-400">
<div class="flex items-center gap-2">
<core:icon name="circle-stack" class="h-4 w-4" />
<span>Database: <strong class="text-zinc-900 dark:text-white">{{ $this->connectionInfo['database'] }}</strong></span>
</div>
<div class="flex items-center gap-2">
<core:icon name="server" class="h-4 w-4" />
<span>Driver: <strong class="text-zinc-900 dark:text-white">{{ $this->connectionInfo['driver'] }}</strong></span>
</div>
</div>
{{-- Query input --}}
<div class="space-y-3">
<flux:textarea
wire:model="query"
placeholder="SELECT * FROM users LIMIT 10;"
rows="4"
class="font-mono text-sm"
/>
<div class="flex items-center gap-2">
<flux:button
wire:click="executeQuery"
wire:loading.attr="disabled"
:disabled="$processing"
variant="primary"
icon="play"
>
<span wire:loading.remove wire:target="executeQuery">Execute</span>
<span wire:loading wire:target="executeQuery">Executing...</span>
</flux:button>
<flux:button
wire:click="clearQuery"
variant="ghost"
icon="x-mark"
>
Clear
</flux:button>
</div>
</div>
{{-- Error display --}}
@if($error)
<div class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-900/20">
<div class="flex items-start gap-3">
<core:icon name="x-circle" class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-400" />
<div class="text-sm text-red-800 dark:text-red-200">
<p class="font-medium">Query failed</p>
<p class="mt-1 font-mono">{{ $error }}</p>
</div>
</div>
</div>
@endif
{{-- Results --}}
@if(!empty($results))
<div class="space-y-3">
{{-- Result stats --}}
<div class="flex items-center justify-between text-sm text-zinc-600 dark:text-zinc-400">
<div class="flex items-center gap-4">
<span>
@if($rowCount > count($results))
Showing {{ count($results) }} of {{ number_format($rowCount) }} rows (limited)
@else
{{ number_format($rowCount) }} {{ Str::plural('row', $rowCount) }}
@endif
</span>
<span>{{ $executionTime }}ms</span>
</div>
<span>{{ count($columns) }} {{ Str::plural('column', count($columns)) }}</span>
</div>
{{-- Results table --}}
<div class="overflow-x-auto rounded-xl border border-zinc-200 dark:border-zinc-700">
<flux:table>
<flux:table.columns>
@foreach($columns as $column)
<flux:table.column class="font-mono text-xs">{{ $column }}</flux:table.column>
@endforeach
</flux:table.columns>
<flux:table.rows>
@foreach($results as $row)
<flux:table.row>
@foreach($columns as $column)
<flux:table.cell class="font-mono text-xs max-w-xs truncate">
@if(is_null($row[$column]))
<span class="text-zinc-400 italic">NULL</span>
@elseif(is_bool($row[$column]))
<span class="text-blue-600 dark:text-blue-400">{{ $row[$column] ? 'true' : 'false' }}</span>
@elseif(is_numeric($row[$column]))
<span class="text-emerald-600 dark:text-emerald-400">{{ $row[$column] }}</span>
@else
{{ Str::limit((string) $row[$column], 100) }}
@endif
</flux:table.cell>
@endforeach
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>
</div>
@elseif(!$error && $query && !$processing && $rowCount === 0 && !empty($columns))
{{-- Empty result set --}}
<div class="rounded-xl border border-zinc-200 bg-white p-8 text-center dark:border-zinc-700 dark:bg-zinc-800">
<core:icon name="table-cells" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">Query returned no results.</p>
</div>
@endif
</div>

View file

@ -0,0 +1,95 @@
{{--
Application Logs viewer.
Displays recent Laravel log entries with filtering by level.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<core:heading size="xl">{{ __('developer::developer.logs.title') }}</core:heading>
<div class="flex items-center gap-2">
<core:button wire:click="refresh" variant="ghost" size="sm">
<core:icon name="arrow-path" class="h-4 w-4" />
{{ __('developer::developer.logs.actions.refresh') }}
</core:button>
<core:button wire:click="downloadLogs" variant="ghost" size="sm">
<core:icon name="arrow-down-tray" class="h-4 w-4" />
{{ __('developer::developer.logs.actions.download') }}
</core:button>
<core:button wire:click="clearLogs" variant="danger" size="sm">
<core:icon name="trash" class="h-4 w-4" />
{{ __('developer::developer.logs.actions.clear') }}
</core:button>
</div>
</div>
{{-- Level filters --}}
<div class="flex flex-wrap items-center gap-2">
@foreach(['error', 'warning', 'info', 'debug'] as $level)
@php
$levelColors = [
'error' => 'red',
'warning' => 'amber',
'info' => 'blue',
'debug' => 'zinc',
];
$color = $levelColors[$level] ?? 'zinc';
@endphp
<core:button
wire:click="setLevel('{{ $level }}')"
variant="{{ $levelFilter === $level ? 'primary' : 'ghost' }}"
size="sm"
>
{{ __('developer::developer.logs.levels.' . $level) }}
</core:button>
@endforeach
@if($levelFilter)
<core:button wire:click="setLevel('')" variant="ghost" size="sm">
<core:icon name="x-mark" class="h-4 w-4" />
{{ __('developer::developer.logs.clear_filter') }}
</core:button>
@endif
</div>
{{-- Logs list --}}
<div class="rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
@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
<div class="flex items-start gap-4 border-b border-zinc-100 p-4 last:border-0 dark:border-zinc-700">
{{-- Level badge --}}
<div class="flex-shrink-0">
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ $color }}">
{{ $log['level'] }}
</span>
</div>
{{-- Message --}}
<div class="min-w-0 flex-1">
<p class="font-mono text-sm text-zinc-900 dark:text-white break-all">
{{ $log['message'] }}
</p>
<p class="mt-1 text-xs text-zinc-400">
{{ $log['time'] }}
</p>
</div>
</div>
@empty
<div class="px-4 py-12 text-center">
<core:icon name="document-text" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('developer::developer.logs.empty') }}
</p>
</div>
@endforelse
</div>
</div>

View file

@ -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.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<core:heading size="xl">{{ __('developer::developer.route_inspector.title') }}</core:heading>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('developer::developer.route_inspector.description') }}
</p>
</div>
<div class="text-sm text-zinc-500">
{{ count($this->filteredRoutes) }} {{ Str::plural('route', count($this->filteredRoutes)) }}
</div>
</div>
{{-- Environment warning --}}
@unless($this->testingAllowed)
<flux:callout variant="danger" icon="exclamation-triangle">
<flux:callout.heading>Testing disabled</flux:callout.heading>
<flux:callout.text>
Route testing is only available in local and testing environments for security reasons.
</flux:callout.text>
</flux:callout>
@endunless
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
{{-- Routes list (left panel) --}}
<div class="xl:col-span-2 space-y-4">
{{-- Filters --}}
<div class="flex flex-wrap items-center gap-4">
{{-- Search --}}
<div class="w-64">
<flux:input
wire:model.live.debounce.300ms="search"
type="search"
placeholder="Search routes..."
icon="magnifying-glass"
/>
</div>
{{-- Method filter --}}
<div class="flex items-center gap-2">
@foreach(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as $method)
<button
wire:click="setMethod('{{ $method }}')"
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium transition {{ $methodFilter === $method ? 'ring-2 ring-offset-1 ring-zinc-400' : '' }} {{ $this->getMethodColour($method) }}"
>
{{ $method }}
</button>
@endforeach
</div>
@if($search || $methodFilter)
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">
Clear
</flux:button>
@endif
</div>
{{-- Routes table --}}
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="max-h-[600px] overflow-y-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-800/50 sticky top-0">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Method
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
URI
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
Name
</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-zinc-500">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse($this->filteredRoutes as $route)
<tr
wire:key="route-{{ md5($route['method'] . $route['uri']) }}"
class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50 cursor-pointer transition"
wire:click="inspectRoute('{{ $route['uri'] }}', '{{ $route['method'] }}')"
>
<td class="whitespace-nowrap px-4 py-3">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium {{ $this->getMethodColour($route['method']) }}">
{{ $route['method'] }}
</span>
@if($route['is_authenticated'])
<flux:icon name="lock-closed" class="inline-block ml-1 w-3 h-3 text-zinc-400" />
@endif
</td>
<td class="px-4 py-3 font-mono text-sm text-zinc-900 dark:text-white">
{{ $route['uri'] }}
@if(count($route['parameters'] ?? []) > 0)
<span class="text-zinc-400 text-xs ml-1">
({{ count($route['parameters']) }} {{ Str::plural('param', count($route['parameters'])) }})
</span>
@endif
</td>
<td class="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ $route['name'] ?? '-' }}
</td>
<td class="px-4 py-3 text-right" wire:click.stop>
<div class="flex items-center justify-end gap-1">
@if($route['method'] === 'GET' && empty($route['parameters']))
<flux:button
wire:click="quickTest('{{ $route['uri'] }}', '{{ $route['method'] }}')"
wire:loading.attr="disabled"
variant="ghost"
size="xs"
icon="play"
:disabled="!$this->testingAllowed"
>
Test
</flux:button>
@endif
<flux:button
wire:click="inspectRoute('{{ $route['uri'] }}', '{{ $route['method'] }}')"
variant="ghost"
size="xs"
icon="magnifying-glass"
>
Inspect
</flux:button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-12 text-center">
<flux:icon name="map" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
No routes found matching your criteria.
</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
{{-- History panel (right sidebar) --}}
<div class="space-y-4">
<div class="bg-white dark:bg-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-700 p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-zinc-900 dark:text-white">Recent Tests</h3>
@if(count($history) > 0)
<flux:button wire:click="clearHistory" variant="ghost" size="xs" icon="trash">
Clear
</flux:button>
@endif
</div>
@if(count($history) > 0)
<div class="space-y-2 max-h-[500px] overflow-y-auto">
@foreach($history as $index => $item)
<button
wire:click="loadFromHistory({{ $index }})"
class="w-full text-left p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 transition"
>
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium {{ $this->getMethodColour($item['method']) }}">
{{ $item['method'] }}
</span>
<span class="font-mono text-xs text-zinc-700 dark:text-zinc-300 truncate flex-1">
{{ $item['uri'] }}
</span>
</div>
<div class="flex items-center justify-between mt-1 text-xs text-zinc-500">
<span class="{{ $this->getStatusColour($item['status_code']) }}">
{{ $item['status_code'] }}
</span>
<span>{{ $item['response_time'] }}</span>
<span>{{ $item['timestamp'] }}</span>
</div>
</button>
@endforeach
</div>
@else
<p class="text-sm text-zinc-500 dark:text-zinc-400 text-center py-8">
No tests run yet. Click on a route to inspect and test it.
</p>
@endif
</div>
</div>
</div>
{{-- Inspector modal/drawer --}}
<flux:modal wire:model="showInspector" class="max-w-4xl">
@if($selectedRoute)
<div class="space-y-6">
{{-- Route header --}}
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<span class="inline-flex items-center rounded px-2 py-1 text-sm font-medium {{ $this->getMethodColour($selectedRoute['method']) }}">
{{ $selectedRoute['method'] }}
</span>
<code class="font-mono text-lg text-zinc-900 dark:text-white">{{ $selectedRoute['uri'] }}</code>
</div>
@if($selectedRoute['name'])
<p class="mt-1 text-sm text-zinc-500">
<span class="font-medium">Name:</span> {{ $selectedRoute['name'] }}
</p>
@endif
<p class="mt-1 text-sm text-zinc-500">
<span class="font-medium">Action:</span>
<code class="text-xs">{{ $selectedRoute['action'] }}</code>
</p>
</div>
@if($selectedRoute['is_destructive'])
<flux:badge color="red" size="sm" icon="exclamation-triangle">
Destructive
</flux:badge>
@endif
</div>
{{-- Route details --}}
<div class="grid grid-cols-2 gap-4 text-sm">
@if($selectedRoute['middleware_string'])
<div class="col-span-2">
<span class="font-medium text-zinc-700 dark:text-zinc-300">Middleware:</span>
<code class="ml-2 text-xs text-zinc-500">{{ $selectedRoute['middleware_string'] }}</code>
</div>
@endif
@if(count($selectedRoute['methods'] ?? []) > 1)
<div>
<span class="font-medium text-zinc-700 dark:text-zinc-300">Methods:</span>
<div class="flex gap-1 mt-1">
@foreach($selectedRoute['methods'] as $method)
<button
wire:click="$set('selectedMethod', '{{ $method }}')"
class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition {{ $selectedMethod === $method ? 'ring-2 ring-offset-1' : 'opacity-50' }} {{ $this->getMethodColour($method) }}"
>
{{ $method }}
</button>
@endforeach
</div>
</div>
@endif
</div>
{{-- Destructive warning --}}
@if(in_array($selectedMethod, ['DELETE', 'PUT', 'PATCH', 'POST']))
<flux:callout variant="warning" icon="exclamation-triangle">
<flux:callout.text>
This is a {{ $selectedMethod }} request. It may modify data in your local database.
</flux:callout.text>
</flux:callout>
@endif
{{-- Request builder --}}
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-6 space-y-4">
<h4 class="font-semibold text-zinc-900 dark:text-white">Request Builder</h4>
{{-- Route parameters --}}
@if(count($selectedRoute['parameters'] ?? []) > 0)
<div class="space-y-3">
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
Route Parameters
</label>
@foreach($selectedRoute['parameters'] as $param)
<flux:input
wire:model="parameters.{{ $param['name'] }}"
label="{{ $param['name'] }}{{ $param['required'] ? ' *' : '' }}"
placeholder="{{ $param['pattern'] ? 'Pattern: ' . $param['pattern'] : 'Enter value...' }}"
/>
@endforeach
</div>
@endif
{{-- Query parameters --}}
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
Query Parameters
</label>
<flux:button wire:click="addQueryParam" variant="ghost" size="xs" icon="plus">
Add
</flux:button>
</div>
@foreach($queryParams as $index => $param)
<div class="flex items-center gap-2" wire:key="query-param-{{ $index }}">
<flux:input
wire:model="queryParams.{{ $index }}.key"
placeholder="Key"
class="flex-1"
/>
<flux:input
wire:model="queryParams.{{ $index }}.value"
placeholder="Value"
class="flex-1"
/>
<flux:button
wire:click="removeQueryParam({{ $index }})"
variant="ghost"
size="sm"
icon="x-mark"
/>
</div>
@endforeach
</div>
{{-- Body (for POST/PUT/PATCH) --}}
@if(in_array($selectedMethod, ['POST', 'PUT', 'PATCH']))
<div>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Request Body (JSON)
</label>
<flux:textarea
wire:model="bodyContent"
rows="4"
placeholder='{"key": "value"}'
class="font-mono text-sm"
/>
</div>
@endif
{{-- Custom headers --}}
<div>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Custom Headers (one per line: Name: Value)
</label>
<flux:textarea
wire:model="customHeaders"
rows="2"
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
class="font-mono text-sm"
/>
</div>
{{-- Authentication option --}}
<div class="flex items-center gap-3">
<flux:checkbox
wire:model="useAuthentication"
label="Use current session authentication"
/>
@if($selectedRoute['is_authenticated'])
<flux:badge size="sm" color="amber">
Route requires auth
</flux:badge>
@endif
</div>
</div>
{{-- Execute button --}}
<div class="flex items-center gap-3">
<flux:button
wire:click="executeTest"
wire:loading.attr="disabled"
variant="primary"
icon="play"
:disabled="!$this->testingAllowed"
>
<span wire:loading.remove wire:target="executeTest">Execute Request</span>
<span wire:loading wire:target="executeTest">Executing...</span>
</flux:button>
<flux:button wire:click="copyAsCurl" variant="ghost" size="sm" icon="clipboard">
Copy as cURL
</flux:button>
</div>
{{-- Response panel --}}
@if($lastResult)
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-6 space-y-4">
<div class="flex items-center justify-between">
<h4 class="font-semibold text-zinc-900 dark:text-white">Response</h4>
<div class="flex items-center gap-4 text-sm">
<span class="{{ $this->getStatusColour($lastResult['status_code']) }} font-semibold">
{{ $lastResult['status_code'] }} {{ $lastResult['status_text'] }}
</span>
@if(isset($lastResult['response_time_formatted']))
<span class="text-zinc-500">{{ $lastResult['response_time_formatted'] }}</span>
@endif
@if(isset($lastResult['memory_usage_formatted']))
<span class="text-zinc-500">{{ $lastResult['memory_usage_formatted'] }}</span>
@endif
</div>
</div>
{{-- Exception display --}}
@if(isset($lastResult['exception']) && $lastResult['exception'])
<flux:callout variant="danger" icon="exclamation-circle">
<flux:callout.heading>{{ $lastResult['exception']['class'] }}</flux:callout.heading>
<flux:callout.text>
{{ $lastResult['exception']['message'] }}
<br>
<code class="text-xs">{{ $lastResult['exception']['file'] }}:{{ $lastResult['exception']['line'] }}</code>
</flux:callout.text>
</flux:callout>
@endif
{{-- Response headers --}}
@if(isset($lastResult['headers']) && count($lastResult['headers']) > 0)
<details class="text-sm">
<summary class="cursor-pointer font-medium text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white">
Headers ({{ count($lastResult['headers']) }})
</summary>
<div class="mt-2 p-3 bg-zinc-50 dark:bg-zinc-900 rounded-lg font-mono text-xs space-y-1">
@foreach($lastResult['headers'] as $name => $value)
<div>
<span class="text-zinc-500">{{ $name }}:</span>
<span class="text-zinc-700 dark:text-zinc-300">{{ Str::limit($value, 100) }}</span>
</div>
@endforeach
</div>
</details>
@endif
{{-- Response body --}}
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Body
@if(isset($lastResult['body_length']))
<span class="font-normal text-zinc-500">({{ number_format($lastResult['body_length']) }} bytes)</span>
@endif
</span>
<flux:button wire:click="copyResponse" variant="ghost" size="xs" icon="clipboard">
Copy
</flux:button>
</div>
<div class="relative">
<pre class="bg-zinc-900 dark:bg-zinc-950 rounded-lg p-4 overflow-x-auto text-sm {{ $lastResult['is_json'] ?? false ? 'text-emerald-400' : 'text-zinc-300' }} whitespace-pre-wrap max-h-[400px] overflow-y-auto">{{ Str::limit($lastResult['body'] ?? '', 10000) }}</pre>
</div>
</div>
</div>
@endif
{{-- Footer --}}
<div class="flex justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700">
<flux:button wire:click="closeInspector" variant="ghost">
Close
</flux:button>
</div>
</div>
@endif
</flux:modal>
</div>
@script
<script>
// Handle copy to clipboard events
$wire.on('copy-to-clipboard', ({ content }) => {
navigator.clipboard.writeText(content).then(() => {
// Success handled by toast in component
}).catch(err => {
console.error('Failed to copy:', err);
});
});
</script>
@endscript

View file

@ -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.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<core:heading size="xl">{{ __('developer::developer.routes.title') }}</core:heading>
<div class="flex items-center gap-4">
<div class="text-sm text-zinc-500">
{{ __('developer::developer.routes.count', ['count' => count($this->filteredRoutes)]) }}
</div>
<flux:button href="{{ route('hub.dev.route-inspector') }}" variant="ghost" size="sm" icon="beaker">
Route Inspector
</flux:button>
</div>
</div>
{{-- Filters --}}
<div class="flex flex-wrap items-center gap-4">
{{-- Search --}}
<div class="w-64">
<core:input
wire:model.live.debounce.300ms="search"
type="search"
placeholder="{{ __('developer::developer.routes.search_placeholder') }}"
icon="magnifying-glass"
/>
</div>
{{-- Method filter --}}
<div class="flex items-center gap-2">
@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
<button
wire:click="setMethod('{{ $method }}')"
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium transition {{ $methodFilter === $method ? 'ring-2 ring-offset-1 ring-zinc-400' : '' }} {{ $methodColors[$method] }}"
>
{{ $method }}
</button>
@endforeach
</div>
@if($search || $methodFilter)
<core:button wire:click="$set('search', ''); $set('methodFilter', '')" variant="ghost" size="sm">
<core:icon name="x-mark" class="h-4 w-4" />
{{ __('developer::developer.routes.clear') }}
</core:button>
@endif
</div>
{{-- Routes table --}}
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-800/50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('developer::developer.routes.table.method') }}
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('developer::developer.routes.table.uri') }}
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('developer::developer.routes.table.name') }}
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('developer::developer.routes.table.action') }}
</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-zinc-500">
Test
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 bg-white dark:divide-zinc-700 dark:bg-zinc-800">
@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
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50 group">
<td class="whitespace-nowrap px-4 py-3">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium {{ $color }}">
{{ $route['method'] }}
</span>
</td>
<td class="px-4 py-3 font-mono text-sm text-zinc-900 dark:text-white">
<a
href="{{ $inspectorUrl }}"
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{{ $route['uri'] }}
</a>
</td>
<td class="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ $route['name'] ?? '-' }}
</td>
<td class="px-4 py-3 font-mono text-xs text-zinc-500 dark:text-zinc-400 truncate max-w-xs" title="{{ $route['action'] }}">
{{ $route['action'] }}
</td>
<td class="px-4 py-3 text-right">
<a
href="{{ $inspectorUrl }}"
class="inline-flex items-center gap-1 text-xs text-zinc-500 hover:text-blue-600 dark:hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<flux:icon name="beaker" class="w-4 h-4" />
Test
</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-12 text-center">
<core:icon name="map" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('developer::developer.routes.empty') }}
</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,221 @@
{{--
Server Management.
CRUD interface for managing SSH server connections.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<core:heading size="xl">Server Management</core:heading>
<flux:button wire:click="openCreateModal" variant="primary" icon="plus">
Add Server
</flux:button>
</div>
{{-- Test result notification --}}
@if($testResult)
<flux:callout :variant="$testSuccess ? 'success' : 'danger'" :icon="$testSuccess ? 'check-circle' : 'exclamation-circle'">
{{ $testResult }}
</flux:callout>
@endif
{{-- Servers table --}}
<flux:table>
<flux:table.columns>
<flux:table.column>Name</flux:table.column>
<flux:table.column>Connection</flux:table.column>
<flux:table.column>Status</flux:table.column>
<flux:table.column>Last Connected</flux:table.column>
<flux:table.column class="text-right">Actions</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($this->servers as $server)
<flux:table.row>
<flux:table.cell>
<div class="font-medium text-zinc-900 dark:text-white">{{ $server->name }}</div>
</flux:table.cell>
<flux:table.cell>
<code class="text-sm bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded font-mono">
{{ $server->connection_string }}
</code>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="match($server->status) {
'connected' => 'green',
'pending' => 'amber',
'failed' => 'red',
default => 'zinc'
}">
{{ ucfirst($server->status) }}
</flux:badge>
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
{{ $server->last_connected_at?->diffForHumans() ?? 'Never' }}
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center justify-end gap-2">
<flux:button
wire:click="testConnection({{ $server->id }})"
wire:loading.attr="disabled"
wire:target="testConnection({{ $server->id }})"
variant="ghost"
size="sm"
icon="signal"
>
<span wire:loading.remove wire:target="testConnection({{ $server->id }})">Test</span>
<span wire:loading wire:target="testConnection({{ $server->id }})">Testing...</span>
</flux:button>
<flux:button
wire:click="openEditModal({{ $server->id }})"
variant="ghost"
size="sm"
icon="pencil"
>
Edit
</flux:button>
<flux:button
wire:click="confirmDelete({{ $server->id }})"
variant="ghost"
size="sm"
icon="trash"
class="text-red-600 hover:text-red-700"
>
Delete
</flux:button>
</div>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="flex flex-col items-center py-12">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="server" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No servers configured</flux:heading>
<flux:subheading class="mt-1">Add your first server to get started with remote management.</flux:subheading>
<flux:button wire:click="openCreateModal" variant="primary" icon="plus" class="mt-4">
Add Server
</flux:button>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
{{-- Create/Edit modal --}}
<flux:modal wire:model="showEditModal" class="max-w-lg">
<div class="space-y-6">
<flux:heading size="lg">
{{ $editingServerId ? 'Edit Server' : 'Add Server' }}
</flux:heading>
<div class="space-y-4">
{{-- Server name --}}
<div>
<flux:label for="name">Server Name</flux:label>
<flux:input
id="name"
wire:model="name"
placeholder="Production Server"
/>
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- IP Address --}}
<div>
<flux:label for="ip">IP Address or Hostname</flux:label>
<flux:input
id="ip"
wire:model="ip"
placeholder="192.168.1.100 or server.example.com"
/>
@error('ip')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Port and User --}}
<div class="grid grid-cols-2 gap-4">
<div>
<flux:label for="port">SSH Port</flux:label>
<flux:input
id="port"
wire:model="port"
type="number"
min="1"
max="65535"
/>
@error('port')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<flux:label for="user">SSH User</flux:label>
<flux:input
id="user"
wire:model="user"
placeholder="root"
/>
@error('user')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
{{-- Private Key --}}
<div>
<flux:label for="private_key">
Private Key
@if($editingServerId)
<span class="text-zinc-500 font-normal">(leave empty to keep existing)</span>
@endif
</flux:label>
<flux:textarea
id="private_key"
wire:model="private_key"
rows="6"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----"
class="font-mono text-xs"
/>
@error('private_key')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<p class="mt-1 text-xs text-zinc-500">
The private key is encrypted at rest and never displayed after saving.
</p>
</div>
</div>
<div class="flex justify-end gap-3">
<flux:button wire:click="closeEditModal" variant="ghost">
Cancel
</flux:button>
<flux:button wire:click="save" variant="primary">
{{ $editingServerId ? 'Update Server' : 'Add Server' }}
</flux:button>
</div>
</div>
</flux:modal>
{{-- Delete confirmation modal --}}
<flux:modal wire:model="showDeleteConfirmation" class="max-w-md">
<div class="space-y-4">
<flux:heading size="lg">Delete Server</flux:heading>
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Are you sure you want to delete this server? This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<flux:button wire:click="cancelDelete" variant="ghost">Cancel</flux:button>
<flux:button wire:click="deleteServer" variant="danger">Delete Server</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,19 @@
<x-pulse>
<livewire:pulse.servers cols="full" />
<livewire:pulse.usage cols="4" rows="2" />
<livewire:pulse.queues cols="4" />
<livewire:pulse.cache cols="4" />
<livewire:pulse.slow-queries cols="8" />
<livewire:pulse.exceptions cols="6" />
<livewire:pulse.slow-requests cols="6" />
<livewire:pulse.slow-jobs cols="6" />
<livewire:pulse.slow-outgoing-requests cols="6" />
</x-pulse>

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Spatie\Activitylog\Models\Activity;
#[Title('Activity Log')]
#[Layout('hub::admin.layouts.app')]
class ActivityLog extends Component
{
use WithPagination;
#[Url(as: 'search')]
public string $searchTerm = '';
#[Url(as: 'type')]
public string $filterSubjectType = '';
#[Url(as: 'event')]
public string $filterEvent = '';
public function updatingSearchTerm(): void
{
$this->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');
}
}

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Artisan;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Cache Management')]
#[Layout('hub::admin.layouts.app')]
class Cache extends Component
{
public string $lastAction = '';
public string $lastOutput = '';
public bool $showConfirmation = false;
public string $pendingAction = '';
public string $confirmationMessage = '';
public function mount(): void
{
$this->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');
}
}

View file

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Database Query')]
#[Layout('hub::admin.layouts.app')]
class Database extends Component
{
public string $query = '';
public array $results = [];
public array $columns = [];
public string $error = '';
public bool $processing = false;
public int $rowCount = 0;
public float $executionTime = 0;
public int $maxRows = 500;
protected const MAX_ROWS = 500;
protected const ALLOWED_STATEMENTS = ['SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN'];
public function mount(): void
{
$this->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');
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Mod\Developer\Services\LogReaderService;
#[Title('Application Logs')]
#[Layout('hub::admin.layouts.app')]
class Logs extends Component
{
public array $logs = [];
public int $limit = 50;
public string $levelFilter = '';
public function mount(): void
{
$this->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');
}
}

View file

@ -0,0 +1,482 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Mod\Developer\Data\RouteTestResult;
use Mod\Developer\Services\RouteTestService;
/**
* Route Inspector - interactive route testing for developers.
*
* Allows testing routes directly from the browser with custom parameters,
* headers, and body content. Only available in local/testing environments.
*/
#[Title('Route Inspector')]
#[Layout('hub::admin.layouts.app')]
class RouteInspector extends Component
{
// Search/filter state
#[Url]
public string $search = '';
#[Url]
public string $methodFilter = '';
// Selected route state
public bool $showInspector = false;
public ?array $selectedRoute = null;
public string $selectedMethod = 'GET';
// Request builder state
public array $parameters = [];
public array $queryParams = [];
public string $bodyContent = '';
public string $customHeaders = '';
public bool $useAuthentication = false;
// Response state
public ?array $lastResult = null;
public bool $executing = false;
// History
public array $history = [];
protected RouteTestService $routeTestService;
public function boot(RouteTestService $routeTestService): void
{
$this->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');
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Title('Application Routes')]
#[Layout('hub::admin.layouts.app')]
class Routes extends Component
{
#[Url]
public string $search = '';
#[Url]
public string $methodFilter = '';
public array $routes = [];
public function mount(): void
{
$this->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');
}
}

View file

@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Mod\Developer\View\Modal\Admin;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Mod\Developer\Models\Server;
#[Title('Server Management')]
#[Layout('hub::admin.layouts.app')]
class Servers extends Component
{
// Modal states
public bool $showEditModal = false;
public bool $showDeleteConfirmation = false;
public ?int $editingServerId = null;
public ?int $deletingServerId = null;
// Form fields
public string $name = '';
public string $ip = '';
public int $port = 22;
public string $user = 'root';
public string $private_key = '';
// Testing state
public ?int $testingServerId = null;
public ?string $testResult = null;
public bool $testSuccess = false;
public function mount(): void
{
$this->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');
}
}

View file

@ -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": {