Skip to content

Commit 86288ac

Browse files
committed
Add status page badges
1 parent d627d22 commit 86288ac

10 files changed

Lines changed: 190 additions & 0 deletions

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"require": {
2222
"php": "^8.3",
2323
"ext-simplexml": "*",
24+
"cachethq/badger": "^5.0",
2425
"doctrine/dbal": "^3.6",
2526
"filament/filament": "^5.0",
2627
"filament/spatie-laravel-settings-plugin": "^5.0",

src/CachetCoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Cachet;
44

55
use BladeUI\Icons\Factory;
6+
use Cachet\Badger\BadgerServiceProvider;
67
use Cachet\Commands\AssetsCommand;
78
use Cachet\Commands\CheckComponentsCommand;
89
use Cachet\Commands\MakeUserCommand;
@@ -64,6 +65,7 @@ public function register(): void
6465
$this->app->singleton(Cachet::class);
6566
$this->app->singleton(ViewManager::class);
6667
$this->app->scoped(Status::class);
68+
$this->app->register(BadgerServiceProvider::class);
6769

6870
$this->configureSettingsCache();
6971
}

src/Enums/ComponentStatusEnum.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ public function getTextColorClasses(): string
7979
};
8080
}
8181

82+
public function getBadgeColor(): string
83+
{
84+
return match ($this) {
85+
self::operational => 'brightgreen',
86+
self::performance_issues => 'yellow',
87+
self::partial_outage => 'orange',
88+
self::major_outage => 'red',
89+
self::under_maintenance => 'orange',
90+
self::unknown => 'lightgray',
91+
};
92+
}
93+
8294
public function getColor(): array
8395
{
8496
return match ($this) {

src/Enums/SystemStatusEnum.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ public function getColor(): array
3434
};
3535
}
3636

37+
public function getBadgeColor(): string
38+
{
39+
return match ($this) {
40+
self::operational => 'brightgreen',
41+
self::partial_outage => 'orange',
42+
self::major_outage => 'red',
43+
self::under_maintenance => 'orange',
44+
};
45+
}
46+
3747
public function getIcon(): string
3848
{
3949
return match ($this) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Cachet\Http\Controllers\StatusPage;
4+
5+
use Cachet\Badger\Badger;
6+
use Cachet\Enums\ResourceVisibilityEnum;
7+
use Cachet\Http\Controllers\StatusPage\Concerns\InteractsWithBadges;
8+
use Cachet\Models\Component;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Http\Response;
11+
12+
class ComponentBadgeController
13+
{
14+
use InteractsWithBadges;
15+
16+
/**
17+
* Render a badge for a publicly visible component.
18+
*/
19+
public function __invoke(Request $request, Component $component, Badger $badger): Response
20+
{
21+
$component->loadMissing('group');
22+
23+
abort_if(! $component->enabled || ($component->group?->visible !== null && $component->group->visible !== ResourceVisibilityEnum::guest), 404);
24+
25+
return $this->response(
26+
$badger->generate(
27+
$component->name,
28+
$component->latest_status->getLabel(),
29+
$component->latest_status->getBadgeColor(),
30+
$this->style($request),
31+
),
32+
);
33+
}
34+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Cachet\Http\Controllers\StatusPage\Concerns;
4+
5+
use Cachet\Badger\BadgeImage;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Http\Response;
8+
9+
trait InteractsWithBadges
10+
{
11+
/**
12+
* Return the requested style, falling back to the default for unsupported values.
13+
*/
14+
protected function style(Request $request): string
15+
{
16+
$style = $request->string('style', 'flat-square')->value();
17+
18+
return in_array($style, ['flat-square', 'plastic-flat', 'flat', 'plastic', 'social', 'svg'], true)
19+
? $style
20+
: 'flat-square';
21+
}
22+
23+
/**
24+
* Build a cacheable SVG response.
25+
*/
26+
protected function response(BadgeImage $badge): Response
27+
{
28+
return response((string) $badge, 200, [
29+
'Cache-Control' => 'public, max-age=60, s-maxage=60',
30+
'Content-Type' => 'image/svg+xml; charset=UTF-8',
31+
]);
32+
}
33+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Cachet\Http\Controllers\StatusPage;
4+
5+
use Cachet\Badger\Badger;
6+
use Cachet\Http\Controllers\StatusPage\Concerns\InteractsWithBadges;
7+
use Cachet\Status;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Http\Response;
10+
11+
class StatusBadgeController
12+
{
13+
use InteractsWithBadges;
14+
15+
/**
16+
* Render a badge for the status page's current system status.
17+
*/
18+
public function __invoke(Request $request, Status $status, Badger $badger): Response
19+
{
20+
$systemStatus = $status->current();
21+
22+
return $this->response(
23+
$badger->generate('status', $systemStatus->getLabel(), $systemStatus->getBadgeColor(), $this->style($request)),
24+
);
25+
}
26+
}

src/PendingRouteRegistration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use Cachet\Http\Controllers\HealthController;
77
use Cachet\Http\Controllers\RssController;
88
use Cachet\Http\Controllers\Setup\SetupController;
9+
use Cachet\Http\Controllers\StatusPage\ComponentBadgeController;
10+
use Cachet\Http\Controllers\StatusPage\StatusBadgeController;
911
use Cachet\Http\Controllers\StatusPage\StatusPageController;
1012
use Cachet\Http\Controllers\Subscribers\SubscriberController;
1113
use Cachet\Http\Controllers\Subscribers\UnsubscribeSubscriberController;
@@ -36,6 +38,8 @@ public function register(): void
3638
$router->get('/', [StatusPageController::class, 'index'])->name('status-page');
3739
$router->get('/incidents/{incident:guid}', [StatusPageController::class, 'show'])->name('status-page.incident');
3840
$router->get('/schedules/{schedule}', [StatusPageController::class, 'schedule'])->name('status-page.schedule');
41+
$router->get('/badge.svg', StatusBadgeController::class)->name('status-page.badge');
42+
$router->get('/components/{component}/badge.svg', ComponentBadgeController::class)->name('status-page.component.badge');
3943

4044
$router->get('/setup', [SetupController::class, 'index'])->name('setup.index');
4145
$router->post('/setup', [SetupController::class, 'store'])->name('setup.store');

tests/Feature/StatusPage/StatusPageTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,50 @@
1414
->assertOk();
1515
});
1616

17+
it('renders an SVG badge for the overall status page status', function () {
18+
Component::factory()->create(['status' => ComponentStatusEnum::operational]);
19+
20+
$response = $this->get(route('cachet.status-page.badge'))
21+
->assertOk()
22+
->assertHeader('content-type', 'image/svg+xml; charset=UTF-8')
23+
->assertSee('<svg', escape: false)
24+
->assertSee('All systems are operational.');
25+
26+
expect($response->headers->get('cache-control'))
27+
->toContain('public')
28+
->toContain('max-age=60')
29+
->toContain('s-maxage=60');
30+
});
31+
32+
it('renders an SVG badge for a public component', function () {
33+
$component = Component::factory()->create([
34+
'name' => 'Public API',
35+
'status' => ComponentStatusEnum::major_outage,
36+
]);
37+
38+
$this->get(route('cachet.status-page.component.badge', $component))
39+
->assertOk()
40+
->assertHeader('content-type', 'image/svg+xml; charset=UTF-8')
41+
->assertSee('<svg', escape: false)
42+
->assertSee('Public API')
43+
->assertSee('Major outage');
44+
});
45+
46+
it('does not render a badge for a component in a private group', function () {
47+
$group = ComponentGroup::factory()->create(['visible' => 0]);
48+
$component = Component::factory()->create(['component_group_id' => $group->id]);
49+
50+
$this->get(route('cachet.status-page.component.badge', $component))
51+
->assertNotFound();
52+
});
53+
54+
it('does not render a badge for a disabled component', function () {
55+
$component = Component::factory()->create(['enabled' => false]);
56+
57+
$this->get(route('cachet.status-page.component.badge', $component))
58+
->assertNotFound();
59+
});
60+
1761
it('can hide the site name and about content without changing status page metadata', function () {
1862
$settings = app(AppSettings::class);
1963
$settings->name = 'Acme Status';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
use Cachet\Enums\ComponentStatusEnum;
4+
use Cachet\Enums\SystemStatusEnum;
5+
6+
it('maps component statuses to badge colors', function (ComponentStatusEnum $status, string $color) {
7+
expect($status->getBadgeColor())->toBe($color);
8+
})->with([
9+
[ComponentStatusEnum::operational, 'brightgreen'],
10+
[ComponentStatusEnum::performance_issues, 'yellow'],
11+
[ComponentStatusEnum::partial_outage, 'orange'],
12+
[ComponentStatusEnum::major_outage, 'red'],
13+
[ComponentStatusEnum::unknown, 'lightgray'],
14+
[ComponentStatusEnum::under_maintenance, 'orange'],
15+
]);
16+
17+
it('maps system statuses to badge colors', function (SystemStatusEnum $status, string $color) {
18+
expect($status->getBadgeColor())->toBe($color);
19+
})->with([
20+
[SystemStatusEnum::operational, 'brightgreen'],
21+
[SystemStatusEnum::partial_outage, 'orange'],
22+
[SystemStatusEnum::major_outage, 'red'],
23+
[SystemStatusEnum::under_maintenance, 'orange'],
24+
]);

0 commit comments

Comments
 (0)