core-developer module
This commit is contained in:
parent
c19612d751
commit
579d88b123
34 changed files with 5013 additions and 40 deletions
146
app/Mod/Developer/Boot.php
Normal file
146
app/Mod/Developer/Boot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
321
app/Mod/Developer/Concerns/RemoteServerManager.php
Normal file
321
app/Mod/Developer/Concerns/RemoteServerManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
72
app/Mod/Developer/Console/Commands/CopyDeviceFrames.php
Normal file
72
app/Mod/Developer/Console/Commands/CopyDeviceFrames.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
app/Mod/Developer/Controllers/DevController.php
Normal file
113
app/Mod/Developer/Controllers/DevController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
209
app/Mod/Developer/Data/RouteTestResult.php
Normal file
209
app/Mod/Developer/Data/RouteTestResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Mod/Developer/Exceptions/SshConnectionException.php
Normal file
30
app/Mod/Developer/Exceptions/SshConnectionException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
130
app/Mod/Developer/Lang/en_GB/developer.php
Normal file
130
app/Mod/Developer/Lang/en_GB/developer.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
40
app/Mod/Developer/Listeners/SetHadesCookie.php
Normal file
40
app/Mod/Developer/Listeners/SetHadesCookie.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
34
app/Mod/Developer/Middleware/ApplyIconSettings.php
Normal file
34
app/Mod/Developer/Middleware/ApplyIconSettings.php
Normal 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);
|
||||
}
|
||||
}
|
||||
36
app/Mod/Developer/Middleware/RequireHades.php
Normal file
36
app/Mod/Developer/Middleware/RequireHades.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
182
app/Mod/Developer/Models/Server.php
Normal file
182
app/Mod/Developer/Models/Server.php
Normal 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');
|
||||
}
|
||||
}
|
||||
56
app/Mod/Developer/Providers/HorizonServiceProvider.php
Normal file
56
app/Mod/Developer/Providers/HorizonServiceProvider.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
82
app/Mod/Developer/Providers/TelescopeServiceProvider.php
Normal file
82
app/Mod/Developer/Providers/TelescopeServiceProvider.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
51
app/Mod/Developer/Routes/admin.php
Normal file
51
app/Mod/Developer/Routes/admin.php
Normal 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');
|
||||
});
|
||||
290
app/Mod/Developer/Services/LogReaderService.php
Normal file
290
app/Mod/Developer/Services/LogReaderService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
411
app/Mod/Developer/Services/RouteTestService.php
Normal file
411
app/Mod/Developer/Services/RouteTestService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
82
app/Mod/Developer/Tests/UseCase/DevToolsBasic.php
Normal file
82
app/Mod/Developer/Tests/UseCase/DevToolsBasic.php
Normal 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'));
|
||||
});
|
||||
});
|
||||
115
app/Mod/Developer/View/Blade/admin/activity-log.blade.php
Normal file
115
app/Mod/Developer/View/Blade/admin/activity-log.blade.php
Normal 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>
|
||||
134
app/Mod/Developer/View/Blade/admin/cache.blade.php
Normal file
134
app/Mod/Developer/View/Blade/admin/cache.blade.php
Normal 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>
|
||||
134
app/Mod/Developer/View/Blade/admin/database.blade.php
Normal file
134
app/Mod/Developer/View/Blade/admin/database.blade.php
Normal 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>
|
||||
95
app/Mod/Developer/View/Blade/admin/logs.blade.php
Normal file
95
app/Mod/Developer/View/Blade/admin/logs.blade.php
Normal 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>
|
||||
466
app/Mod/Developer/View/Blade/admin/route-inspector.blade.php
Normal file
466
app/Mod/Developer/View/Blade/admin/route-inspector.blade.php
Normal 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 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
|
||||
145
app/Mod/Developer/View/Blade/admin/routes.blade.php
Normal file
145
app/Mod/Developer/View/Blade/admin/routes.blade.php
Normal 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>
|
||||
221
app/Mod/Developer/View/Blade/admin/servers.blade.php
Normal file
221
app/Mod/Developer/View/Blade/admin/servers.blade.php
Normal 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>
|
||||
19
app/Mod/Developer/View/Blade/vendor/pulse/dashboard.blade.php
vendored
Normal file
19
app/Mod/Developer/View/Blade/vendor/pulse/dashboard.blade.php
vendored
Normal 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>
|
||||
122
app/Mod/Developer/View/Modal/Admin/ActivityLog.php
Normal file
122
app/Mod/Developer/View/Modal/Admin/ActivityLog.php
Normal 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');
|
||||
}
|
||||
}
|
||||
142
app/Mod/Developer/View/Modal/Admin/Cache.php
Normal file
142
app/Mod/Developer/View/Modal/Admin/Cache.php
Normal 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');
|
||||
}
|
||||
}
|
||||
153
app/Mod/Developer/View/Modal/Admin/Database.php
Normal file
153
app/Mod/Developer/View/Modal/Admin/Database.php
Normal 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');
|
||||
}
|
||||
}
|
||||
110
app/Mod/Developer/View/Modal/Admin/Logs.php
Normal file
110
app/Mod/Developer/View/Modal/Admin/Logs.php
Normal 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');
|
||||
}
|
||||
}
|
||||
482
app/Mod/Developer/View/Modal/Admin/RouteInspector.php
Normal file
482
app/Mod/Developer/View/Modal/Admin/RouteInspector.php
Normal 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');
|
||||
}
|
||||
}
|
||||
101
app/Mod/Developer/View/Modal/Admin/Routes.php
Normal file
101
app/Mod/Developer/View/Modal/Admin/Routes.php
Normal 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');
|
||||
}
|
||||
}
|
||||
234
app/Mod/Developer/View/Modal/Admin/Servers.php
Normal file
234
app/Mod/Developer/View/Modal/Admin/Servers.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue