diff --git a/composer.json b/composer.json index c16e0058..644b044e 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "require": { "php": "^8.3", "ext-simplexml": "*", + "cachethq/badger": "^5.0", "doctrine/dbal": "^3.6", "filament/filament": "^5.0", "filament/spatie-laravel-settings-plugin": "^5.0", diff --git a/src/CachetCoreServiceProvider.php b/src/CachetCoreServiceProvider.php index e80dbe38..3a7617a0 100644 --- a/src/CachetCoreServiceProvider.php +++ b/src/CachetCoreServiceProvider.php @@ -3,6 +3,7 @@ namespace Cachet; use BladeUI\Icons\Factory; +use Cachet\Badger\BadgerServiceProvider; use Cachet\Commands\AssetsCommand; use Cachet\Commands\CheckComponentsCommand; use Cachet\Commands\MakeUserCommand; @@ -64,6 +65,7 @@ public function register(): void $this->app->singleton(Cachet::class); $this->app->singleton(ViewManager::class); $this->app->scoped(Status::class); + $this->app->register(BadgerServiceProvider::class); $this->configureSettingsCache(); } diff --git a/src/Enums/ComponentStatusEnum.php b/src/Enums/ComponentStatusEnum.php index 5d27fa9b..fa811c2b 100644 --- a/src/Enums/ComponentStatusEnum.php +++ b/src/Enums/ComponentStatusEnum.php @@ -79,6 +79,18 @@ public function getTextColorClasses(): string }; } + public function getBadgeColor(): string + { + return match ($this) { + self::operational => 'brightgreen', + self::performance_issues => 'yellow', + self::partial_outage => 'orange', + self::major_outage => 'red', + self::under_maintenance => 'orange', + self::unknown => 'lightgray', + }; + } + public function getColor(): array { return match ($this) { diff --git a/src/Enums/SystemStatusEnum.php b/src/Enums/SystemStatusEnum.php index 58ef9c23..0fb43e9f 100644 --- a/src/Enums/SystemStatusEnum.php +++ b/src/Enums/SystemStatusEnum.php @@ -34,6 +34,16 @@ public function getColor(): array }; } + public function getBadgeColor(): string + { + return match ($this) { + self::operational => 'brightgreen', + self::partial_outage => 'orange', + self::major_outage => 'red', + self::under_maintenance => 'orange', + }; + } + public function getIcon(): string { return match ($this) { diff --git a/src/Http/BadgeResponseFactory.php b/src/Http/BadgeResponseFactory.php new file mode 100644 index 00000000..199220b9 --- /dev/null +++ b/src/Http/BadgeResponseFactory.php @@ -0,0 +1,33 @@ + 'public, max-age=60, s-maxage=60', + 'Content-Type' => 'image/svg+xml; charset=UTF-8', + ]); + } + + /** + * Return the requested style, falling back to the default for unsupported values. + */ + public function style(Request $request): string + { + $style = $request->string('style', 'flat-square')->value(); + + return in_array($style, ['flat-square', 'plastic-flat', 'flat', 'plastic', 'social', 'svg'], true) + ? $style + : 'flat-square'; + } +} diff --git a/src/Http/Controllers/StatusPage/ComponentBadgeController.php b/src/Http/Controllers/StatusPage/ComponentBadgeController.php new file mode 100644 index 00000000..b389f105 --- /dev/null +++ b/src/Http/Controllers/StatusPage/ComponentBadgeController.php @@ -0,0 +1,32 @@ +loadMissing('group'); + + abort_if(! $component->enabled || ($component->group?->visible !== null && $component->group->visible !== ResourceVisibilityEnum::guest), 404); + + return $badgeResponseFactory->make( + $badger->generate( + $component->name, + $component->latest_status->getLabel(), + $component->latest_status->getBadgeColor(), + $badgeResponseFactory->style($request), + ), + ); + } +} diff --git a/src/Http/Controllers/StatusPage/StatusBadgeController.php b/src/Http/Controllers/StatusPage/StatusBadgeController.php new file mode 100644 index 00000000..5752f2a6 --- /dev/null +++ b/src/Http/Controllers/StatusPage/StatusBadgeController.php @@ -0,0 +1,24 @@ +current(); + + return $badgeResponseFactory->make( + $badger->generate('status', $systemStatus->getLabel(), $systemStatus->getBadgeColor(), $badgeResponseFactory->style($request)), + ); + } +} diff --git a/src/PendingRouteRegistration.php b/src/PendingRouteRegistration.php index d1e2c57b..449647cc 100644 --- a/src/PendingRouteRegistration.php +++ b/src/PendingRouteRegistration.php @@ -6,6 +6,8 @@ use Cachet\Http\Controllers\HealthController; use Cachet\Http\Controllers\RssController; use Cachet\Http\Controllers\Setup\SetupController; +use Cachet\Http\Controllers\StatusPage\ComponentBadgeController; +use Cachet\Http\Controllers\StatusPage\StatusBadgeController; use Cachet\Http\Controllers\StatusPage\StatusPageController; use Cachet\Http\Controllers\Subscribers\SubscriberController; use Cachet\Http\Controllers\Subscribers\UnsubscribeSubscriberController; @@ -36,6 +38,8 @@ public function register(): void $router->get('/', [StatusPageController::class, 'index'])->name('status-page'); $router->get('/incidents/{incident:guid}', [StatusPageController::class, 'show'])->name('status-page.incident'); $router->get('/schedules/{schedule}', [StatusPageController::class, 'schedule'])->name('status-page.schedule'); + $router->get('/badge.svg', StatusBadgeController::class)->name('status-page.badge'); + $router->get('/components/{component}/badge.svg', ComponentBadgeController::class)->name('status-page.component.badge'); $router->get('/setup', [SetupController::class, 'index'])->name('setup.index'); $router->post('/setup', [SetupController::class, 'store'])->name('setup.store'); diff --git a/tests/Feature/StatusPage/StatusPageTest.php b/tests/Feature/StatusPage/StatusPageTest.php index 1afb1bfc..2da0f76b 100644 --- a/tests/Feature/StatusPage/StatusPageTest.php +++ b/tests/Feature/StatusPage/StatusPageTest.php @@ -14,6 +14,50 @@ ->assertOk(); }); +it('renders an SVG badge for the overall status page status', function () { + Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + $response = $this->get(route('cachet.status-page.badge')) + ->assertOk() + ->assertHeader('content-type', 'image/svg+xml; charset=UTF-8') + ->assertSee('assertSee('All systems are operational.'); + + expect($response->headers->get('cache-control')) + ->toContain('public') + ->toContain('max-age=60') + ->toContain('s-maxage=60'); +}); + +it('renders an SVG badge for a public component', function () { + $component = Component::factory()->create([ + 'name' => 'Public API', + 'status' => ComponentStatusEnum::major_outage, + ]); + + $this->get(route('cachet.status-page.component.badge', $component)) + ->assertOk() + ->assertHeader('content-type', 'image/svg+xml; charset=UTF-8') + ->assertSee('assertSee('Public API') + ->assertSee('Major outage'); +}); + +it('does not render a badge for a component in a private group', function () { + $group = ComponentGroup::factory()->create(['visible' => 0]); + $component = Component::factory()->create(['component_group_id' => $group->id]); + + $this->get(route('cachet.status-page.component.badge', $component)) + ->assertNotFound(); +}); + +it('does not render a badge for a disabled component', function () { + $component = Component::factory()->create(['enabled' => false]); + + $this->get(route('cachet.status-page.component.badge', $component)) + ->assertNotFound(); +}); + it('can hide the site name and about content without changing status page metadata', function () { $settings = app(AppSettings::class); $settings->name = 'Acme Status'; diff --git a/tests/Unit/StatusBadgeColorTest.php b/tests/Unit/StatusBadgeColorTest.php new file mode 100644 index 00000000..02486eff --- /dev/null +++ b/tests/Unit/StatusBadgeColorTest.php @@ -0,0 +1,24 @@ +getBadgeColor())->toBe($color); +})->with([ + [ComponentStatusEnum::operational, 'brightgreen'], + [ComponentStatusEnum::performance_issues, 'yellow'], + [ComponentStatusEnum::partial_outage, 'orange'], + [ComponentStatusEnum::major_outage, 'red'], + [ComponentStatusEnum::unknown, 'lightgray'], + [ComponentStatusEnum::under_maintenance, 'orange'], +]); + +it('maps system statuses to badge colors', function (SystemStatusEnum $status, string $color) { + expect($status->getBadgeColor())->toBe($color); +})->with([ + [SystemStatusEnum::operational, 'brightgreen'], + [SystemStatusEnum::partial_outage, 'orange'], + [SystemStatusEnum::major_outage, 'red'], + [SystemStatusEnum::under_maintenance, 'orange'], +]);