From 44684c93e5fa0ccb86810b473148a5fc66b4e4e0 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 12:14:58 +0100 Subject: [PATCH] Enterprise Services administration dashboard * Database health checks * Cache health checks * Storage write and disk-space checks * Failed queue job monitoring * Production environment diagnostics * Persisted health-check history * Queue retry controls * Scheduler execution controls * Cache and optimisation clearing * Backup creation and retention * Backup deletion * SQLite database backup support * Backup manifests and module metadata * Artisan health and backup commands * Administration navigation * Enterprise permissions * Module registration and tests --- Modules/Enterprise/Config/config.php | 10 +++ .../Console/BackupCreateCommand.php | 12 +++ .../Enterprise/Console/BackupPruneCommand.php | 12 +++ .../Enterprise/Console/HealthCheckCommand.php | 24 ++++++ ...00_create_enterprise_health_runs_table.php | 23 +++++ ...100100_create_enterprise_backups_table.php | 26 ++++++ .../Http/Controllers/EnterpriseController.php | 74 ++++++++++++++++ Modules/Enterprise/Models/Backup.php | 14 ++++ Modules/Enterprise/Models/HealthRun.php | 14 ++++ .../Providers/EnterpriseServiceProvider.php | 29 +++++++ .../Providers/RouteServiceProvider.php | 13 +++ .../Resources/views/admin/index.blade.php | 24 ++++++ Modules/Enterprise/Routes/web.php | 17 ++++ Modules/Enterprise/Services/BackupService.php | 66 +++++++++++++++ .../Services/SystemHealthService.php | 84 +++++++++++++++++++ .../Feature/EnterpriseModuleBootTest.php | 8 ++ Modules/Enterprise/module.json | 1 + .../Database/Seeders/RolePermissionSeeder.php | 2 + modules_statuses.json | 3 +- .../components/layouts/app/sidebar.blade.php | 2 +- 20 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 Modules/Enterprise/Config/config.php create mode 100644 Modules/Enterprise/Console/BackupCreateCommand.php create mode 100644 Modules/Enterprise/Console/BackupPruneCommand.php create mode 100644 Modules/Enterprise/Console/HealthCheckCommand.php create mode 100644 Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php create mode 100644 Modules/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php create mode 100644 Modules/Enterprise/Http/Controllers/EnterpriseController.php create mode 100644 Modules/Enterprise/Models/Backup.php create mode 100644 Modules/Enterprise/Models/HealthRun.php create mode 100644 Modules/Enterprise/Providers/EnterpriseServiceProvider.php create mode 100644 Modules/Enterprise/Providers/RouteServiceProvider.php create mode 100644 Modules/Enterprise/Resources/views/admin/index.blade.php create mode 100644 Modules/Enterprise/Routes/web.php create mode 100644 Modules/Enterprise/Services/BackupService.php create mode 100644 Modules/Enterprise/Services/SystemHealthService.php create mode 100644 Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php create mode 100644 Modules/Enterprise/module.json diff --git a/Modules/Enterprise/Config/config.php b/Modules/Enterprise/Config/config.php new file mode 100644 index 0000000..12b78c1 --- /dev/null +++ b/Modules/Enterprise/Config/config.php @@ -0,0 +1,10 @@ + env('ENTERPRISE_BACKUP_DISK', 'local'), + 'backup_path' => env('ENTERPRISE_BACKUP_PATH', 'backups'), + 'retention_days' => (int) env('ENTERPRISE_BACKUP_RETENTION_DAYS', 30), + 'health' => [ + 'minimum_free_disk_mb' => (int) env('ENTERPRISE_MIN_FREE_DISK_MB', 512), + 'maximum_failed_jobs' => (int) env('ENTERPRISE_MAX_FAILED_JOBS', 0), + ], +]; diff --git a/Modules/Enterprise/Console/BackupCreateCommand.php b/Modules/Enterprise/Console/BackupCreateCommand.php new file mode 100644 index 0000000..feeebc9 --- /dev/null +++ b/Modules/Enterprise/Console/BackupCreateCommand.php @@ -0,0 +1,12 @@ +create(); $this->info('Backup created: '.$backup->path); return self::SUCCESS; } +} diff --git a/Modules/Enterprise/Console/BackupPruneCommand.php b/Modules/Enterprise/Console/BackupPruneCommand.php new file mode 100644 index 0000000..c77a56c --- /dev/null +++ b/Modules/Enterprise/Console/BackupPruneCommand.php @@ -0,0 +1,12 @@ +info($service->prune().' backup(s) pruned.'); return self::SUCCESS; } +} diff --git a/Modules/Enterprise/Console/HealthCheckCommand.php b/Modules/Enterprise/Console/HealthCheckCommand.php new file mode 100644 index 0000000..9961fd5 --- /dev/null +++ b/Modules/Enterprise/Console/HealthCheckCommand.php @@ -0,0 +1,24 @@ +run(); + HealthRun::create(['status' => $result['status'], 'results' => $result, 'ran_at' => now()]); + if ($this->option('json')) $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + else { + $this->table(['Check', 'Status', 'Message'], collect($result['checks'])->map(fn (array $check) => [$check['name'], $check['status'], $check['message']])->all()); + $this->line('Overall: '.$result['status']); + } + return $result['status'] === 'failed' ? self::FAILURE : self::SUCCESS; + } +} diff --git a/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php b/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php new file mode 100644 index 0000000..12c8f3f --- /dev/null +++ b/Modules/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status', 20); + $table->json('results'); + $table->timestamp('ran_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('enterprise_health_runs'); + } +}; diff --git a/Modules/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php b/Modules/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php new file mode 100644 index 0000000..a16e465 --- /dev/null +++ b/Modules/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('disk'); + $table->string('path'); + $table->unsignedBigInteger('size')->default(0); + $table->string('status', 20)->default('completed'); + $table->text('message')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('enterprise_backups'); + } +}; diff --git a/Modules/Enterprise/Http/Controllers/EnterpriseController.php b/Modules/Enterprise/Http/Controllers/EnterpriseController.php new file mode 100644 index 0000000..fbcd0c4 --- /dev/null +++ b/Modules/Enterprise/Http/Controllers/EnterpriseController.php @@ -0,0 +1,74 @@ + $health->run(), + 'latestRun' => HealthRun::latest('ran_at')->first(), + 'backups' => Backup::latest()->limit(10)->get(), + 'failedJobs' => DB::getSchemaBuilder()->hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0, + 'queueConnection' => config('queue.default'), + 'cacheStore' => config('cache.default'), + 'schedulerConfigured' => file_exists(base_path('routes/console.php')), + 'phpVersion' => PHP_VERSION, + 'laravelVersion' => app()->version(), + ]); + } + + public function runHealth(SystemHealthService $service): RedirectResponse + { + $result = $service->run(); + HealthRun::create(['user_id' => auth()->id(), 'status' => $result['status'], 'results' => $result, 'ran_at' => now()]); + return back()->with('status', 'Health checks completed.'); + } + + public function clearCache(): RedirectResponse + { + Artisan::call('optimize:clear'); + Cache::flush(); + return back()->with('status', 'Application and cache stores cleared.'); + } + + public function runScheduler(): RedirectResponse + { + Artisan::call('schedule:run'); + return back()->with('status', trim(Artisan::output()) ?: 'Scheduler executed.'); + } + + public function retryFailedJobs(): RedirectResponse + { + Artisan::call('queue:retry', ['id' => 'all']); + return back()->with('status', trim(Artisan::output()) ?: 'Failed jobs queued for retry.'); + } + + public function createBackup(BackupService $service): RedirectResponse + { + try { + $service->create(auth()->id()); + return back()->with('status', 'Backup created.'); + } catch (Throwable $e) { + report($e); + return back()->withErrors(['backup' => $e->getMessage()]); + } + } + + public function deleteBackup(Backup $backup, BackupService $service): RedirectResponse + { + $service->delete($backup); + return back()->with('status', 'Backup deleted.'); + } +} diff --git a/Modules/Enterprise/Models/Backup.php b/Modules/Enterprise/Models/Backup.php new file mode 100644 index 0000000..3d5ba6e --- /dev/null +++ b/Modules/Enterprise/Models/Backup.php @@ -0,0 +1,14 @@ + 'datetime']; } + public function user(): BelongsTo { return $this->belongsTo(User::class); } +} diff --git a/Modules/Enterprise/Models/HealthRun.php b/Modules/Enterprise/Models/HealthRun.php new file mode 100644 index 0000000..eba66b0 --- /dev/null +++ b/Modules/Enterprise/Models/HealthRun.php @@ -0,0 +1,14 @@ + 'array', 'ran_at' => 'datetime']; } + public function user(): BelongsTo { return $this->belongsTo(User::class); } +} diff --git a/Modules/Enterprise/Providers/EnterpriseServiceProvider.php b/Modules/Enterprise/Providers/EnterpriseServiceProvider.php new file mode 100644 index 0000000..59418dc --- /dev/null +++ b/Modules/Enterprise/Providers/EnterpriseServiceProvider.php @@ -0,0 +1,29 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Enterprise', 'Config/config.php'), 'enterprise'); + $this->app->singleton(SystemHealthService::class); + $this->app->singleton(BackupService::class); + } + + public function boot(): void + { + $this->loadMigrationsFrom(module_path('Enterprise', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Enterprise', 'Resources/views'), 'enterprise'); + if ($this->app->runningInConsole()) { + $this->commands([HealthCheckCommand::class, BackupCreateCommand::class, BackupPruneCommand::class]); + } + } +} diff --git a/Modules/Enterprise/Providers/RouteServiceProvider.php b/Modules/Enterprise/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..98d6fd1 --- /dev/null +++ b/Modules/Enterprise/Providers/RouteServiceProvider.php @@ -0,0 +1,13 @@ +group(module_path('Enterprise', 'Routes/web.php')); + } +} diff --git a/Modules/Enterprise/Resources/views/admin/index.blade.php b/Modules/Enterprise/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..d337ff9 --- /dev/null +++ b/Modules/Enterprise/Resources/views/admin/index.blade.php @@ -0,0 +1,24 @@ + +
+

{{ __('Enterprise Services') }}

{{ __('Health, queues, scheduler, cache, backups and production diagnostics.') }}

+ @if(session('status')){{ session('status') }}@endif + @if($errors->any()){{ $errors->first() }}@endif + +
+
{{ __('Overall health') }}
{{ $health['status'] }}
+
{{ __('Queue') }}
{{ $queueConnection }}
{{ $failedJobs }} {{ __('failed') }}
+
{{ __('Cache') }}
{{ $cacheStore }}
+
{{ __('Runtime') }}
PHP {{ $phpVersion }}
Laravel {{ $laravelVersion }}
+
+ + +

{{ __('Health checks') }}

@csrf{{ __('Run checks') }}
+
@foreach($health['checks'] as $check)
{{ $check['name'] }}{{ $check['status'] }}{{ $check['message'] }}
@endforeach
+
+ +
+

{{ __('Operations') }}

@csrf{{ __('Clear cache') }}
@csrf{{ __('Run scheduler') }}
@csrf{{ __('Retry failed jobs') }}
+

{{ __('Backups') }}

@csrf{{ __('Create backup') }}
@forelse($backups as $backup)
{{ $backup->path }}
{{ number_format($backup->size / 1024 / 1024, 2) }} MB ยท {{ $backup->created_at->diffForHumans() }}
@csrf @method('DELETE'){{ __('Delete') }}
@empty

{{ __('No backups created.') }}

@endforelse
+
+
+
diff --git a/Modules/Enterprise/Routes/web.php b/Modules/Enterprise/Routes/web.php new file mode 100644 index 0000000..94b1601 --- /dev/null +++ b/Modules/Enterprise/Routes/web.php @@ -0,0 +1,17 @@ +prefix('administration/enterprise') + ->name('administration.enterprise.') + ->controller(EnterpriseController::class) + ->group(function (): void { + Route::get('/', 'index')->name('index'); + Route::post('/health', 'runHealth')->middleware('permission:enterprise.manage')->name('health.run'); + Route::post('/cache/clear', 'clearCache')->middleware('permission:enterprise.manage')->name('cache.clear'); + Route::post('/scheduler/run', 'runScheduler')->middleware('permission:enterprise.manage')->name('scheduler.run'); + Route::post('/queue/retry', 'retryFailedJobs')->middleware('permission:enterprise.manage')->name('queue.retry'); + Route::post('/backups', 'createBackup')->middleware('permission:enterprise.manage')->name('backups.store'); + Route::delete('/backups/{backup}', 'deleteBackup')->middleware('permission:enterprise.manage')->name('backups.destroy'); + }); diff --git a/Modules/Enterprise/Services/BackupService.php b/Modules/Enterprise/Services/BackupService.php new file mode 100644 index 0000000..d97967a --- /dev/null +++ b/Modules/Enterprise/Services/BackupService.php @@ -0,0 +1,66 @@ +format('Ymd-His').'.zip'; + $relativePath = $directory.'/'.$name; + $temporary = storage_path('app/'.$name); + $zip = new ZipArchive(); + if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) throw new RuntimeException('Unable to create backup archive.'); + + $manifest = json_encode([ + 'application' => config('app.name'), + 'environment' => app()->environment(), + 'created_at' => now()->toIso8601String(), + 'php' => PHP_VERSION, + 'laravel' => app()->version(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $zip->addFromString('manifest.json', $manifest ?: '{}'); + $zip->addFile(base_path('composer.json'), 'composer.json'); + if (File::exists(base_path('modules_statuses.json'))) $zip->addFile(base_path('modules_statuses.json'), 'modules_statuses.json'); + if (config('database.default') === 'sqlite') { + $database = database_path('database.sqlite'); + if (File::exists($database)) $zip->addFile($database, 'database/database.sqlite'); + } + $zip->close(); + + Storage::disk($disk)->put($relativePath, File::get($temporary)); + File::delete($temporary); + + return Backup::create([ + 'user_id' => $userId, + 'disk' => $disk, + 'path' => $relativePath, + 'size' => Storage::disk($disk)->size($relativePath), + 'status' => 'completed', + 'completed_at' => now(), + ]); + } + + public function delete(Backup $backup): void + { + Storage::disk($backup->disk)->delete($backup->path); + $backup->delete(); + } + + public function prune(): int + { + $cutoff = now()->subDays((int) config('enterprise.retention_days', 30)); + $backups = Backup::query()->where('created_at', '<', $cutoff)->get(); + $backups->each(fn (Backup $backup) => $this->delete($backup)); + return $backups->count(); + } +} diff --git a/Modules/Enterprise/Services/SystemHealthService.php b/Modules/Enterprise/Services/SystemHealthService.php new file mode 100644 index 0000000..01d1348 --- /dev/null +++ b/Modules/Enterprise/Services/SystemHealthService.php @@ -0,0 +1,84 @@ +database(), + $this->cache(), + $this->storage(), + $this->queue(), + $this->environment(), + ]; + + return [ + 'status' => collect($checks)->contains(fn (array $check): bool => $check['status'] === 'failed') ? 'failed' : (collect($checks)->contains(fn (array $check): bool => $check['status'] === 'warning') ? 'warning' : 'healthy'), + 'checks' => $checks, + 'generated_at' => now()->toIso8601String(), + ]; + } + + private function database(): array + { + try { + DB::connection()->getPdo(); + return $this->result('Database', 'healthy', DB::connection()->getDriverName()); + } catch (Throwable $e) { + return $this->result('Database', 'failed', $e->getMessage()); + } + } + + private function cache(): array + { + try { + $key = 'enterprise-health-'.bin2hex(random_bytes(6)); + Cache::put($key, 'ok', 10); + $healthy = Cache::get($key) === 'ok'; + Cache::forget($key); + return $this->result('Cache', $healthy ? 'healthy' : 'failed', config('cache.default')); + } catch (Throwable $e) { + return $this->result('Cache', 'failed', $e->getMessage()); + } + } + + private function storage(): array + { + try { + Storage::disk('local')->put('enterprise-health.txt', now()->toIso8601String()); + Storage::disk('local')->delete('enterprise-health.txt'); + $freeMb = (int) floor(disk_free_space(storage_path()) / 1024 / 1024); + $minimum = (int) config('enterprise.health.minimum_free_disk_mb', 512); + return $this->result('Storage', $freeMb < $minimum ? 'warning' : 'healthy', $freeMb.' MB free'); + } catch (Throwable $e) { + return $this->result('Storage', 'failed', $e->getMessage()); + } + } + + private function queue(): array + { + $failed = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0; + $maximum = (int) config('enterprise.health.maximum_failed_jobs', 0); + return $this->result('Queue', $failed > $maximum ? 'warning' : 'healthy', $failed.' failed jobs'); + } + + private function environment(): array + { + $warnings = []; + if (app()->environment('production') && config('app.debug')) $warnings[] = 'APP_DEBUG is enabled'; + if (! app()->environment('production')) $warnings[] = 'Not running in production'; + return $this->result('Environment', $warnings ? 'warning' : 'healthy', $warnings ? implode('; ', $warnings) : app()->environment()); + } + + private function result(string $name, string $status, string $message): array + { + return compact('name', 'status', 'message'); + } +} diff --git a/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php b/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php new file mode 100644 index 0000000..1715ea5 --- /dev/null +++ b/Modules/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php @@ -0,0 +1,8 @@ + expect(app()->providerIsLoaded(Modules\Enterprise\Providers\EnterpriseServiceProvider::class))->toBeTrue()); +it('runs platform health checks', function (): void { + $result = app(SystemHealthService::class)->run(); + expect($result)->toHaveKeys(['status', 'checks', 'generated_at'])->and($result['checks'])->not->toBeEmpty(); +}); diff --git a/Modules/Enterprise/module.json b/Modules/Enterprise/module.json new file mode 100644 index 0000000..fdce53e --- /dev/null +++ b/Modules/Enterprise/module.json @@ -0,0 +1 @@ +{"name":"Enterprise","alias":"enterprise","version":"1.0.0","description":"Health, diagnostics, cache, queues, scheduler, backups and storage operations.","keywords":["health","diagnostics","queue","scheduler","backup"],"priority":100,"providers":["Modules\\Enterprise\\Providers\\EnterpriseServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 87be214..255c2d3 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -61,6 +61,8 @@ final class RolePermissionSeeder extends Seeder 'developer.access', 'api.view', 'api.manage', + 'enterprise.view', + 'enterprise.manage', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index a0025aa..4f07025 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -35,5 +35,6 @@ "Localization": true, "Sites": true, "Developer": true, - "Api": true + "Api": true, + "Enterprise": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 2de4af9..3afd230 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -31,7 +31,7 @@ {{ __('Team') }} {{ __('Testimonials') }} -{{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} +{{ __('Enterprise Services') }}{{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} {{ __('API & Webhooks') }}{{ __('Developer Tools') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }}