diff --git a/core/App/Container/Registrars/AppServicesRegistrar.php b/core/App/Container/Registrars/AppServicesRegistrar.php index 5574766..b8940b9 100644 --- a/core/App/Container/Registrars/AppServicesRegistrar.php +++ b/core/App/Container/Registrars/AppServicesRegistrar.php @@ -41,7 +41,6 @@ use MintyPHP\Service\Security\RateLimiterService; use MintyPHP\Service\Security\SecurityServicesFactory; use MintyPHP\Service\Stats\AdminStatsViewDataService; use MintyPHP\Service\System\SystemHealthService; -use MintyPHP\Service\System\SystemInfoService; final class AppServicesRegistrar implements ContainerRegistrar { @@ -56,11 +55,6 @@ final class AppServicesRegistrar implements ContainerRegistrar $container->set(SystemHealthService::class, static fn (AppContainer $c): SystemHealthService => new SystemHealthService( $c->get(SystemHealthRepository::class) )); - $container->set(SystemInfoService::class, static fn (AppContainer $c): SystemInfoService => new SystemInfoService( - $c->get(SystemHealthService::class), - $c->get(SystemHealthRepository::class), - $c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null - )); $container->set(SearchDataService::class, static fn (AppContainer $c): SearchDataService => new SearchDataService( $c->get(PermissionService::class), $c->get(UserTenantRepository::class), diff --git a/core/Service/System/SystemHealthService.php b/core/Service/System/SystemHealthService.php index 91a27fb..ab04a58 100644 --- a/core/Service/System/SystemHealthService.php +++ b/core/Service/System/SystemHealthService.php @@ -36,7 +36,7 @@ class SystemHealthService } /** - * @return list + * @return list */ public function runAll(): array { @@ -51,10 +51,13 @@ class SystemHealthService } /** - * @return array{status: string, name: string, message: string} + * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkDatabase(): array { + $label = 'Database'; + $okDescription = 'Connection established'; + try { $ok = $this->repository->checkDatabaseConnectivity(); @@ -62,21 +65,27 @@ class SystemHealthService 'status' => $ok ? 'ok' : 'fail', 'name' => 'Database connectivity', 'message' => $ok ? 'connection established' : 'select 1 did not return expected value', + 'label' => $label, + 'description' => $ok ? $okDescription : 'Database did not return the expected response', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Database connectivity', 'message' => 'check failed', + 'label' => $label, + 'description' => 'Database check could not be executed', ]; } } /** - * @return array{status: string, name: string, message: string} + * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkDatabaseSchema(): array { + $label = 'Database schema'; + try { $present = $this->repository->listPresentTables(self::REQUIRED_TABLES); sort($present, SORT_STRING); @@ -87,6 +96,8 @@ class SystemHealthService 'status' => 'fail', 'name' => 'Database schema', 'message' => 'missing tables: ' . implode(', ', $missing), + 'label' => $label, + 'description' => 'Required core tables are missing', ]; } @@ -94,21 +105,27 @@ class SystemHealthService 'status' => 'ok', 'name' => 'Database schema', 'message' => sprintf('%d core tables present', count(self::REQUIRED_TABLES)), + 'label' => $label, + 'description' => 'All required core tables are present', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Database schema', 'message' => 'check failed', + 'label' => $label, + 'description' => 'Schema check could not be executed', ]; } } /** - * @return array{status: string, name: string, message: string} + * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkStorageWriteability(): array { + $label = 'Storage'; + try { $storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH ? rtrim((string) APP_STORAGE_PATH, '/') @@ -119,6 +136,8 @@ class SystemHealthService 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'storage directory not found', + 'label' => $label, + 'description' => 'Storage directory is missing', ]; } if (!is_writable($storagePath)) { @@ -126,6 +145,8 @@ class SystemHealthService 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'storage directory not writable', + 'label' => $label, + 'description' => 'Storage directory is not writable', ]; } @@ -136,6 +157,8 @@ class SystemHealthService 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'write probe failed', + 'label' => $label, + 'description' => 'Storage write probe failed', ]; } @unlink($probeFile); @@ -144,21 +167,27 @@ class SystemHealthService 'status' => 'ok', 'name' => 'Storage writeability', 'message' => 'storage path is writable', + 'label' => $label, + 'description' => 'Storage path is writable', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Storage writeability', 'message' => 'check failed', + 'label' => $label, + 'description' => 'Storage check could not be executed', ]; } } /** - * @return array{status: string, name: string, message: string} + * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkRbacBaseline(): array { + $label = 'Permissions'; + try { $present = $this->repository->listActivePermissionKeys(self::REQUIRED_PERMISSIONS); sort($present, SORT_STRING); @@ -169,6 +198,8 @@ class SystemHealthService 'status' => 'fail', 'name' => 'RBAC baseline', 'message' => 'missing active permissions: ' . implode(', ', $missing), + 'label' => $label, + 'description' => 'Baseline permissions are missing', ]; } @@ -176,21 +207,27 @@ class SystemHealthService 'status' => 'ok', 'name' => 'RBAC baseline', 'message' => sprintf('%d baseline permissions active', count(self::REQUIRED_PERMISSIONS)), + 'label' => $label, + 'description' => 'Baseline permissions are present', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'RBAC baseline', 'message' => 'check failed', + 'label' => $label, + 'description' => 'Permission check could not be executed', ]; } } /** - * @return array{status: string, name: string, message: string} + * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkAdminRoleAssignment(): array { + $label = 'Administrators'; + try { $count = $this->repository->countAdminUsers(); @@ -199,6 +236,8 @@ class SystemHealthService 'status' => 'fail', 'name' => 'Admin role assignment', 'message' => 'no active user assigned to Admin/Administrator role', + 'label' => $label, + 'description' => 'No active user has the administrator role', ]; } @@ -206,21 +245,27 @@ class SystemHealthService 'status' => 'ok', 'name' => 'Admin role assignment', 'message' => sprintf('%d admin user(s) assigned', $count), + 'label' => $label, + 'description' => 'At least one administrator is active', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Admin role assignment', 'message' => 'check failed', + 'label' => $label, + 'description' => 'Administrator check could not be executed', ]; } } /** - * @return array{status: string, name: string, message: string} + * @return array{status: string, name: string, message: string, label: string, description: string} */ public function checkSchedulerHeartbeat(): array { + $label = 'Scheduler'; + try { $status = $this->repository->getSchedulerStatus(); @@ -229,6 +274,8 @@ class SystemHealthService 'status' => 'warn', 'name' => 'Scheduler heartbeat', 'message' => 'no scheduler runtime status row found yet', + 'label' => $label, + 'description' => 'Scheduler has not reported yet', ]; } @@ -241,22 +288,29 @@ class SystemHealthService 'status' => 'warn', 'name' => 'Scheduler heartbeat', 'message' => 'scheduler heartbeat is empty', + 'label' => $label, + 'description' => 'Scheduler heartbeat is empty', ]; } $seconds = max(0, time() - strtotime($heartbeat . ' UTC')); $detail = "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')'; + $stale = $seconds > self::SCHEDULER_STALE_THRESHOLD_SECONDS; return [ - 'status' => $seconds > self::SCHEDULER_STALE_THRESHOLD_SECONDS ? 'warn' : 'ok', + 'status' => $stale ? 'warn' : 'ok', 'name' => 'Scheduler heartbeat', 'message' => $detail, + 'label' => $label, + 'description' => $stale ? 'Scheduler heartbeat is outdated' : 'Scheduler is running on schedule', ]; } catch (\Throwable $e) { return [ 'status' => 'fail', 'name' => 'Scheduler heartbeat', 'message' => 'check failed', + 'label' => $label, + 'description' => 'Scheduler check could not be executed', ]; } } diff --git a/core/Service/System/SystemInfoService.php b/core/Service/System/SystemInfoService.php deleted file mode 100644 index 5f48516..0000000 --- a/core/Service/System/SystemInfoService.php +++ /dev/null @@ -1,121 +0,0 @@ - - */ - public function buildPageData(): array - { - return [ - 'overview' => $this->buildOverview(), - 'modules' => $this->buildModuleInventory(), - 'permissions' => $this->buildPermissionSummary(), - ]; - } - - /** - * @return array - */ - private function buildOverview(): array - { - return [ - 'php_version' => PHP_VERSION, - 'php_sapi' => PHP_SAPI, - 'app_environment' => $this->resolveEnvironment(), - 'health_checks' => $this->healthService->runAll(), - ]; - } - - /** - * @return list, permissions_count: int}> - */ - private function buildModuleInventory(): array - { - if ($this->moduleRegistry === null) { - return []; - } - - $modules = []; - foreach ($this->moduleRegistry->getModules() as $manifest) { - $modules[] = [ - 'id' => $manifest->id, - 'version' => $manifest->version, - 'enabled_by_default' => $manifest->enabledByDefault, - 'requires' => $manifest->requires, - 'permissions_count' => count($manifest->permissions), - ]; - } - - usort($modules, static fn (array $a, array $b): int => strcmp($a['id'], $b['id'])); - - return $modules; - } - - /** - * @return array{active_count: int, inactive_count: int, by_source: array} - */ - private function buildPermissionSummary(): array - { - $activeKeys = $this->healthRepository->listAllActivePermissionKeys(); - $bySource = $this->derivePermissionsBySource($activeKeys); - - return [ - 'active_count' => $this->healthRepository->countActivePermissions(), - 'inactive_count' => $this->healthRepository->countInactivePermissions(), - 'by_source' => $bySource, - ]; - } - - /** - * @param list $activeKeys - * @return array - */ - private function derivePermissionsBySource(array $activeKeys): array - { - $moduleKeys = []; - if ($this->moduleRegistry !== null) { - foreach ($this->moduleRegistry->getModules() as $manifest) { - foreach ($manifest->permissions as $perm) { - $key = $perm['key']; - if ($key !== '') { - $moduleKeys[$key] = $manifest->id; - } - } - } - } - - $counts = []; - foreach ($activeKeys as $key) { - $source = $moduleKeys[$key] ?? 'core'; - $counts[$source] = ($counts[$source] ?? 0) + 1; - } - - ksort($counts); - - return $counts; - } - - private function resolveEnvironment(): string - { - if (defined('APP_ENV')) { - return (string) constant('APP_ENV'); - } - - $env = getenv('APP_ENV'); - - return is_string($env) && $env !== '' ? $env : 'unknown'; - } -} diff --git a/i18n/default_de.json b/i18n/default_de.json index 1473cfe..874072c 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1186,6 +1186,39 @@ "Extend session": "Sitzung verlängern", "Log out": "Abmelden", "System Info": "Systeminfo", + "OK": "OK", + "System status": "Systemstatus", + "All systems operational": "Alle Systeme betriebsbereit", + "Degraded service": "Eingeschränkter Betrieb", + "System outage detected": "Systemstörung erkannt", + "Last checked": "Zuletzt geprüft", + "Components": "Komponenten", + "Database": "Datenbank", + "Database schema": "Datenbankschema", + "Storage": "Speicher", + "Administrators": "Administratoren", + "Connection established": "Verbindung hergestellt", + "Database did not return the expected response": "Datenbank hat keine erwartete Antwort geliefert", + "Database check could not be executed": "Datenbank-Prüfung konnte nicht ausgeführt werden", + "All required core tables are present": "Alle erforderlichen Kerntabellen sind vorhanden", + "Required core tables are missing": "Erforderliche Kerntabellen fehlen", + "Schema check could not be executed": "Schema-Prüfung konnte nicht ausgeführt werden", + "Storage path is writable": "Speicherpfad ist beschreibbar", + "Storage directory is missing": "Speicherverzeichnis fehlt", + "Storage directory is not writable": "Speicherverzeichnis ist nicht beschreibbar", + "Storage write probe failed": "Schreibprobe im Speicher fehlgeschlagen", + "Storage check could not be executed": "Speicher-Prüfung konnte nicht ausgeführt werden", + "Baseline permissions are present": "Basisberechtigungen sind vorhanden", + "Baseline permissions are missing": "Basisberechtigungen fehlen", + "Permission check could not be executed": "Berechtigungs-Prüfung konnte nicht ausgeführt werden", + "At least one administrator is active": "Mindestens ein Administrator ist aktiv", + "No active user has the administrator role": "Kein aktiver Nutzer hat die Administrator-Rolle", + "Administrator check could not be executed": "Administrator-Prüfung konnte nicht ausgeführt werden", + "Scheduler is running on schedule": "Scheduler läuft planmäßig", + "Scheduler heartbeat is outdated": "Scheduler-Heartbeat ist veraltet", + "Scheduler has not reported yet": "Scheduler hat sich noch nicht gemeldet", + "Scheduler heartbeat is empty": "Scheduler-Heartbeat ist leer", + "Scheduler check could not be executed": "Scheduler-Prüfung konnte nicht ausgeführt werden", "Health Status": "Systemstatus", "Check": "Prüfung", "Details": "Details", diff --git a/i18n/default_en.json b/i18n/default_en.json index 50799ce..51c173a 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1186,6 +1186,39 @@ "Extend session": "Extend session", "Log out": "Log out", "System Info": "System Info", + "OK": "OK", + "System status": "System status", + "All systems operational": "All systems operational", + "Degraded service": "Degraded service", + "System outage detected": "System outage detected", + "Last checked": "Last checked", + "Components": "Components", + "Database": "Database", + "Database schema": "Database schema", + "Storage": "Storage", + "Administrators": "Administrators", + "Connection established": "Connection established", + "Database did not return the expected response": "Database did not return the expected response", + "Database check could not be executed": "Database check could not be executed", + "All required core tables are present": "All required core tables are present", + "Required core tables are missing": "Required core tables are missing", + "Schema check could not be executed": "Schema check could not be executed", + "Storage path is writable": "Storage path is writable", + "Storage directory is missing": "Storage directory is missing", + "Storage directory is not writable": "Storage directory is not writable", + "Storage write probe failed": "Storage write probe failed", + "Storage check could not be executed": "Storage check could not be executed", + "Baseline permissions are present": "Baseline permissions are present", + "Baseline permissions are missing": "Baseline permissions are missing", + "Permission check could not be executed": "Permission check could not be executed", + "At least one administrator is active": "At least one administrator is active", + "No active user has the administrator role": "No active user has the administrator role", + "Administrator check could not be executed": "Administrator check could not be executed", + "Scheduler is running on schedule": "Scheduler is running on schedule", + "Scheduler heartbeat is outdated": "Scheduler heartbeat is outdated", + "Scheduler has not reported yet": "Scheduler has not reported yet", + "Scheduler heartbeat is empty": "Scheduler heartbeat is empty", + "Scheduler check could not be executed": "Scheduler check could not be executed", "Health Status": "Health Status", "Check": "Check", "Details": "Details", diff --git a/modules/help-center/templates/aside-help-panel.phtml b/modules/help-center/templates/aside-help-panel.phtml index 7e82a12..c071b26 100644 --- a/modules/help-center/templates/aside-help-panel.phtml +++ b/modules/help-center/templates/aside-help-panel.phtml @@ -37,7 +37,7 @@ $helpNavGroups = [ 'icon' => 'bi-info-circle', 'items' => [ [ - 'label' => t('System info'), + 'label' => t('System status'), 'path' => 'admin/system-info', 'active' => navActive('admin/system-info', true), 'visible' => $canViewSystemInfo, diff --git a/pages/admin/system-info/index().php b/pages/admin/system-info/index().php index fe4c47b..16dfe2e 100644 --- a/pages/admin/system-info/index().php +++ b/pages/admin/system-info/index().php @@ -2,20 +2,44 @@ use MintyPHP\Buffer; use MintyPHP\Service\Access\OperationsAuthorizationPolicy; -use MintyPHP\Service\System\SystemInfoService; +use MintyPHP\Service\System\SystemHealthService; use MintyPHP\Support\Guard; Guard::requireAbilityOrForbidden(OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_INFO_VIEW); -$pageData = app(SystemInfoService::class)->buildPageData(); -$overview = $pageData['overview'] ?? []; -$modules = $pageData['modules'] ?? []; -$permissions = $pageData['permissions'] ?? []; -$healthChecks = $overview['health_checks'] ?? []; +$checks = app(SystemHealthService::class)->runAll(); -Buffer::set('title', t('System Info')); +$overall = 'ok'; +foreach ($checks as $check) { + if ($check['status'] === 'fail') { + $overall = 'fail'; + break; + } + if ($check['status'] === 'warn') { + $overall = 'warn'; + } +} + +$environment = 'unknown'; +if (defined('APP_ENV')) { + $environment = (string) constant('APP_ENV'); +} else { + $envVar = getenv('APP_ENV'); + if (is_string($envVar) && $envVar !== '') { + $environment = $envVar; + } +} + +$meta = [ + 'php_version' => PHP_VERSION, + 'environment' => $environment, +]; + +$lastChecked = date('Y-m-d H:i:s'); + +Buffer::set('title', t('System status')); $breadcrumbs = [ ['label' => t('Home'), 'path' => 'admin'], - ['label' => t('System Info')], + ['label' => t('System status')], ]; diff --git a/pages/admin/system-info/index(default).phtml b/pages/admin/system-info/index(default).phtml index c341e59..e2a19b8 100644 --- a/pages/admin/system-info/index(default).phtml +++ b/pages/admin/system-info/index(default).phtml @@ -1,180 +1,100 @@ , permissions_count: int}> $modules - * @var array{active_count: int, inactive_count: int, by_source: array} $permissions - * @var list $healthChecks + * @var list $checks + * @var string $overall + * @var array{php_version: string, environment: string} $meta + * @var string $lastChecked */ -$overview = $overview ?? []; -$modules = $modules ?? []; -$permissions = $permissions ?? []; -$healthChecks = $healthChecks ?? []; +$checks = $checks ?? []; +$overall = $overall ?? 'ok'; +$meta = $meta ?? ['php_version' => '', 'environment' => '']; +$lastChecked = $lastChecked ?? ''; + +$bannerTitles = [ + 'ok' => t('All systems operational'), + 'warn' => t('Degraded service'), + 'fail' => t('System outage detected'), +]; +$bannerIcons = [ + 'ok' => 'bi-check-circle-fill', + 'warn' => 'bi-exclamation-triangle-fill', + 'fail' => 'bi-x-circle-fill', +]; +$bannerVariants = [ + 'ok' => 'success', + 'warn' => 'warn', + 'fail' => 'danger', +]; +$statusBadgeLabel = [ + 'ok' => t('OK'), + 'warn' => t('Warning'), + 'fail' => t('Fail'), +]; + +$bannerTitle = $bannerTitles[$overall] ?? $bannerTitles['ok']; +$bannerIcon = $bannerIcons[$overall] ?? $bannerIcons['ok']; +$bannerVariant = $bannerVariants[$overall] ?? $bannerVariants['ok']; ?>
-

+

-
-
-
- - - -
- -
-
-
- -
- - - - - - - - - - - - - - - -
-
- -
-
- -
- -
-

-
- - - - - - - - - - - - - - - - - - - -
- - OK - - - - - -
- -
-
- - -
- -
-

-
- -
-
- () -
- - - - - - - - - - - - - - - - - - - - - -
-
- -
- - -
-
-
- -
- - - - - - - - - - - -
-
- - - -
-
- -
- - - - - - - - - $count): ?> - - - - - - -
-
- -
+
+ +
+

+ :
+
+ +
+
+ +
+ + t('No health checks available.'), 'size' => 'compact']; require templatePath('partials/app-empty-state.phtml'); ?> + + + + + + + + + + +
+ + + + +
+ +
+ +
+
+ +
+ + + + + + + + + + + +
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 127aa9c..2a42dc1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1159,10 +1159,16 @@ parameters: path: core/Service/Stats/AdminStatsViewDataService.php - - message: '#^Public method "MintyPHP\\Service\\System\\SystemInfoService\:\:buildPageData\(\)" is never used$#' + message: '#^Public method "MintyPHP\\Service\\System\\SystemHealthService\:\:runAll\(\)" is never used$#' identifier: public.method.unused count: 1 - path: core/Service/System/SystemInfoService.php + path: core/Service/System/SystemHealthService.php + + - + message: '#^Public property "MintyPHP\\App\\Module\\ModuleManifest\:\:\$enabledByDefault" is never used$#' + identifier: public.property.unused + count: 1 + path: core/App/Module/ModuleManifest.php - message: '#^Public method "MintyPHP\\Service\\Tenant\\TenantAvatarService\:\:hasAvatar\(\)" is never used$#' diff --git a/tests/Service/System/SystemHealthServiceTest.php b/tests/Service/System/SystemHealthServiceTest.php index b1e2783..664c6fc 100644 --- a/tests/Service/System/SystemHealthServiceTest.php +++ b/tests/Service/System/SystemHealthServiceTest.php @@ -28,6 +28,8 @@ class SystemHealthServiceTest extends TestCase self::assertSame('ok', $result['status']); self::assertSame('Database connectivity', $result['name']); + self::assertSame('Database', $result['label']); + self::assertSame('Connection established', $result['description']); } public function testCheckDatabaseReturnsFailWhenDisconnected(): void @@ -39,6 +41,8 @@ class SystemHealthServiceTest extends TestCase $result = $this->service->checkDatabase(); self::assertSame('fail', $result['status']); + self::assertSame('Database', $result['label']); + self::assertSame('Database did not return the expected response', $result['description']); } public function testCheckDatabaseReturnsFailOnException(): void @@ -190,6 +194,10 @@ class SystemHealthServiceTest extends TestCase self::assertArrayHasKey('status', $check); self::assertArrayHasKey('name', $check); self::assertArrayHasKey('message', $check); + self::assertArrayHasKey('label', $check); + self::assertArrayHasKey('description', $check); + self::assertNotSame('', $check['label']); + self::assertNotSame('', $check['description']); } } } diff --git a/tests/Service/System/SystemInfoServiceTest.php b/tests/Service/System/SystemInfoServiceTest.php deleted file mode 100644 index 006ac74..0000000 --- a/tests/Service/System/SystemInfoServiceTest.php +++ /dev/null @@ -1,147 +0,0 @@ -healthService = $this->createMock(SystemHealthService::class); - $this->healthRepository = $this->createMock(SystemHealthRepositoryInterface::class); - $this->fixturesDir = sys_get_temp_dir() . '/system-info-test-' . uniqid(); - mkdir($this->fixturesDir, 0777, true); - } - - protected function tearDown(): void - { - $this->removeDir($this->fixturesDir); - } - - public function testBuildPageDataReturnsExpectedStructure(): void - { - $service = new SystemInfoService($this->healthService, $this->healthRepository, null); - - $this->healthService->expects($this->once()) - ->method('runAll') - ->willReturn([ - ['status' => 'ok', 'name' => 'DB', 'message' => 'connected'], - ]); - - $this->healthRepository->method('countActivePermissions')->willReturn(10); - $this->healthRepository->method('countInactivePermissions')->willReturn(2); - $this->healthRepository->method('listAllActivePermissionKeys')->willReturn(['users.view', 'settings.view']); - - $data = $service->buildPageData(); - - self::assertArrayHasKey('overview', $data); - self::assertArrayHasKey('modules', $data); - self::assertArrayHasKey('permissions', $data); - - self::assertSame(PHP_VERSION, $data['overview']['php_version']); - self::assertIsArray($data['overview']['health_checks']); - self::assertCount(1, $data['overview']['health_checks']); - } - - public function testModuleInventoryReturnsSortedList(): void - { - $registry = $this->createRegistryWithModules(); - $service = new SystemInfoService($this->healthService, $this->healthRepository, $registry); - - $this->healthService->method('runAll')->willReturn([]); - $this->healthRepository->method('countActivePermissions')->willReturn(0); - $this->healthRepository->method('countInactivePermissions')->willReturn(0); - $this->healthRepository->method('listAllActivePermissionKeys')->willReturn([]); - - $data = $service->buildPageData(); - - self::assertCount(2, $data['modules']); - self::assertSame('alpha', $data['modules'][0]['id']); - self::assertSame('beta', $data['modules'][1]['id']); - self::assertSame('2.0.0', $data['modules'][1]['version']); - self::assertSame(['alpha'], $data['modules'][1]['requires']); - } - - public function testPermissionSummaryReturnsCorrectCounts(): void - { - $service = new SystemInfoService($this->healthService, $this->healthRepository, null); - - $this->healthService->method('runAll')->willReturn([]); - $this->healthRepository->expects($this->once())->method('countActivePermissions')->willReturn(42); - $this->healthRepository->expects($this->once())->method('countInactivePermissions')->willReturn(3); - $this->healthRepository->expects($this->once())->method('listAllActivePermissionKeys')->willReturn([ - 'users.view', 'settings.view', - ]); - - $data = $service->buildPageData(); - - self::assertSame(42, $data['permissions']['active_count']); - self::assertSame(3, $data['permissions']['inactive_count']); - self::assertSame(['core' => 2], $data['permissions']['by_source']); - } - - public function testBuildPageDataWorksWithoutModuleRegistry(): void - { - $service = new SystemInfoService($this->healthService, $this->healthRepository, null); - - $this->healthService->method('runAll')->willReturn([]); - $this->healthRepository->method('countActivePermissions')->willReturn(5); - $this->healthRepository->method('countInactivePermissions')->willReturn(0); - $this->healthRepository->method('listAllActivePermissionKeys')->willReturn([]); - - $data = $service->buildPageData(); - - self::assertSame([], $data['modules']); - } - - private function createRegistryWithModules(): ModuleRegistry - { - mkdir($this->fixturesDir . '/alpha', 0777, true); - file_put_contents( - $this->fixturesDir . '/alpha/module.php', - ' 'alpha', - 'version' => '1.0.0', - 'enabled_by_default' => true, - ], true) . ';' - ); - - mkdir($this->fixturesDir . '/beta', 0777, true); - file_put_contents( - $this->fixturesDir . '/beta/module.php', - ' 'beta', - 'version' => '2.0.0', - 'enabled_by_default' => false, - 'requires' => ['alpha'], - ], true) . ';' - ); - - return ModuleRegistry::boot($this->fixturesDir, ['alpha', 'beta']); - } - - private function removeDir(string $dir): void - { - if (!is_dir($dir)) { - return; - } - $items = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($items as $item) { - $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - rmdir($dir); - } -} diff --git a/web/css/components/app-status-banner.css b/web/css/components/app-status-banner.css new file mode 100644 index 0000000..8a969fd --- /dev/null +++ b/web/css/components/app-status-banner.css @@ -0,0 +1,74 @@ +@layer components { + .app-status-banner { + display: flex; + align-items: center; + gap: 18px; + padding: calc(var(--app-spacing) * 1) calc(var(--app-spacing) * 1.25); + border: 1px solid var(--badge-neutral-border); + border-radius: var(--app-border-radius); + background: var(--badge-neutral-bg); + color: var(--badge-neutral-color); + margin-block-end: var(--app-spacing); + } + + .app-status-banner[data-variant="success"] { + background: var(--badge-success-bg); + color: var(--badge-success-color); + border-color: var(--badge-success-border); + } + + .app-status-banner[data-variant="warn"] { + background: var(--badge-warn-bg); + color: var(--badge-warn-color); + border-color: var(--badge-warn-border); + } + + .app-status-banner[data-variant="danger"] { + background: var(--badge-danger-bg); + color: var(--badge-danger-color); + border-color: var(--badge-danger-border); + } + + .app-status-banner > i { + font-size: var(--text-3xl); + line-height: var(--leading-none); + flex-shrink: 0; + } + + .app-status-banner-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .app-status-banner-text h2 { + margin: 0; + font-size: var(--text-lg); + line-height: var(--leading-snug); + } + + .app-status-banner-text small { + opacity: 0.85; + } + + .app-stats-table .app-status-description { + display: block; + color: var(--app-muted-color); + margin-top: 2px; + } + + .app-stats-table td.app-status-badge-cell { + text-align: end; + white-space: nowrap; + width: 1%; + } + + @media (max-width: 600px) { + .app-status-banner { + flex-direction: column; + align-items: flex-start; + text-align: start; + } + } +} diff --git a/web/css/core.css b/web/css/core.css index 5d60386..a6a22ef 100644 --- a/web/css/core.css +++ b/web/css/core.css @@ -28,6 +28,7 @@ @import url("components/app-list-titlebar.css"); @import url("components/app-details-titlebar.css"); @import url("components/app-dashboard-titlebar.css"); +@import url("components/app-status-banner.css"); @import url("components/app-empty-state.css"); @import url("components/app-details.css"); @import url("components/app-details-card.css");