diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 47a62b12..bb947f44 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -232,6 +232,22 @@ public function run(): void $incident->updates()->save($update); + $incident = Incident::create([ + 'name' => 'Documentation Search Unavailable', + 'message' => 'We are investigating an issue preventing searches in the documentation from returning results.', + 'status' => IncidentStatusEnum::investigating, + 'visible' => ResourceVisibilityEnum::guest, + 'guid' => Str::uuid(), + 'user_id' => $user->id, + 'created_at' => $timestamp = now()->subMinutes(2), + 'updated_at' => $timestamp, + 'occurred_at' => $timestamp, + ]); + + $incident->components()->attach($documentation, [ + 'component_status' => ComponentStatusEnum::partial_outage, + ]); + IncidentTemplate::create([ 'name' => 'Third-Party Service Outage', 'slug' => 'third-party-service-outage', diff --git a/resources/views/components/incident.blade.php b/resources/views/components/incident.blade.php index 25136bb1..d9fca34d 100644 --- a/resources/views/components/incident.blade.php +++ b/resources/views/components/incident.blade.php @@ -67,7 +67,7 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
@foreach ($incident->updates as $update) -
+
@if ($headingLevel === 1)

{{ $update->status->getLabel() }}

@@ -80,7 +80,7 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
{!! $update->formattedMessage() !!}
@endforeach -
+
@php($reportStatus = $incident->updates->isEmpty() ? $incident->status : null) @if ($headingLevel === 1) diff --git a/resources/views/slack-rss.blade.php b/resources/views/slack-rss.blade.php new file mode 100644 index 00000000..6cd95b89 --- /dev/null +++ b/resources/views/slack-rss.blade.php @@ -0,0 +1,39 @@ +'.PHP_EOL +?> + + + {{ $statusPageName }} incident updates + {{ route('cachet.status-page') }} + Latest incident reports and updates from {{ $statusPageName }} + {{ now()->toRssString() }} + https://validator.w3.org/feed/docs/rss2.html + Cachet + @foreach($items as $item) + @php + $incident = $item['incident']; + $update = $item['update']; + $status = $update ? ($update->status?->getLabel() ?? 'Updated') : 'Reported'; + $message = $update?->formattedMessage() ?? $incident->formattedMessage(); + $url = route('cachet.status-page.incident', $incident).'#'.($update ? 'update-'.$update->id : 'reported'); + $title = str_replace(']]>', ']]]]>', '['.$status.'] '.$incident->name); + $description = $message.'

Status: '.e($status); + + if ($incident->components->isNotEmpty()) { + $components = $incident->components->pluck('name')->map(fn (string $name): string => e($name))->join(', '); + $description .= '
Affected components: '.$components; + } + + $description = str_replace(']]>', ']]]]>', $description); + @endphp + + <![CDATA[{!! $title !!}]]> + {{ $url }} + {{ $url }} + {{ $item['publishedAt']->toRssString() }} + + + + @endforeach +
+
diff --git a/src/Http/Controllers/SlackRssController.php b/src/Http/Controllers/SlackRssController.php new file mode 100644 index 00000000..bf64e94a --- /dev/null +++ b/src/Http/Controllers/SlackRssController.php @@ -0,0 +1,135 @@ + $appSettings->name, + 'items' => $this->items($this->feedIncidents($appSettings)), + ])->render(); + }); + + $lastModified = Cache::remember('cachet::slack-rss-feed-last-modified', self::CACHE_TTL, fn () => now()); + + $response = response($feed) + ->header('Content-Type', 'application/rss+xml') + ->setEtag(hash('sha256', $feed)) + ->setLastModified($lastModified) + ->setPublic() + ->setMaxAge(self::CACHE_TTL); + + $response->isNotModified($request); + + return $response; + } + + /** + * Get incidents that can contribute one of the newest feed events. + * + * @return Collection + */ + private function feedIncidents(AppSettings $appSettings): Collection + { + $incidents = $this->incidentQuery($appSettings); + + $incidentIds = (clone $incidents) + ->orderByDesc('created_at') + ->limit(self::MAX_ITEMS) + ->pluck('id'); + + $updatedIncidentIds = Update::query() + ->where('updateable_type', (new Incident)->getMorphClass()) + ->whereIn('updateable_id', (clone $incidents)->select('id')) + ->orderByDesc('created_at') + ->limit(self::MAX_ITEMS) + ->pluck('updateable_id'); + + return (clone $incidents) + ->whereKey($incidentIds->concat($updatedIncidentIds)->unique()) + ->with([ + 'components', + 'updates' => fn ($query) => $query->orderBy('created_at')->orderBy('id'), + ]) + ->get(); + } + + /** + * Build the public incident query shared by both candidate sets. + * + * @return Builder + */ + private function incidentQuery(AppSettings $appSettings): Builder + { + return Incident::query() + ->viewableBy(false) + ->when($appSettings->recent_incidents_only, function (Builder $query) use ($appSettings) { + $query->where(function (Builder $query) use ($appSettings) { + $cutoff = Carbon::now()->subDays($appSettings->recent_incidents_days)->format('Y-m-d'); + + $query->whereDate('occurred_at', '>', $cutoff) + ->orWhere(function (Builder $query) use ($cutoff) { + $query->whereNull('occurred_at')->whereDate('created_at', '>', $cutoff); + }); + }); + }); + } + + /** + * Convert incidents and their updates into one chronological event stream. + * + * @param Collection $incidents + * @return list + */ + private function items(Collection $incidents): array + { + /** @var list $items */ + $items = []; + + foreach ($incidents as $incident) { + $items[] = [ + 'incident' => $incident, + 'update' => null, + 'publishedAt' => $incident->timestamp, + ]; + + foreach ($incident->updates as $update) { + $items[] = [ + 'incident' => $incident, + 'update' => $update, + 'publishedAt' => $update->created_at ?? $incident->timestamp, + ]; + } + } + + usort($items, fn (array $left, array $right): int => $right['publishedAt']->getTimestamp() <=> $left['publishedAt']->getTimestamp()); + + return array_slice($items, 0, self::MAX_ITEMS); + } +} diff --git a/src/Models/Incident.php b/src/Models/Incident.php index af8f821e..1d760c1d 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -274,12 +274,14 @@ protected function latestStatus(): Attribute } /** - * Clear the cached RSS feed and its HTTP validators. + * Clear the cached RSS feeds and their HTTP validators. */ private static function forgetRssFeed(): void { Cache::forget('cachet::rss-feed'); Cache::forget('cachet::rss-feed-last-modified'); + Cache::forget('cachet::slack-rss-feed'); + Cache::forget('cachet::slack-rss-feed-last-modified'); } /** diff --git a/src/Models/Update.php b/src/Models/Update.php index f90d957f..5456c171 100644 --- a/src/Models/Update.php +++ b/src/Models/Update.php @@ -78,13 +78,15 @@ public function formattedMessage(): string } /** - * Clear the RSS feed when an incident update changes its content. + * Clear the RSS feeds when an incident update changes its content. */ private function forgetRssFeed(): void { if ($this->updateable instanceof Incident) { Cache::forget('cachet::rss-feed'); Cache::forget('cachet::rss-feed-last-modified'); + Cache::forget('cachet::slack-rss-feed'); + Cache::forget('cachet::slack-rss-feed-last-modified'); } } diff --git a/src/PendingRouteRegistration.php b/src/PendingRouteRegistration.php index 449647cc..0437c2a7 100644 --- a/src/PendingRouteRegistration.php +++ b/src/PendingRouteRegistration.php @@ -6,6 +6,7 @@ use Cachet\Http\Controllers\HealthController; use Cachet\Http\Controllers\RssController; use Cachet\Http\Controllers\Setup\SetupController; +use Cachet\Http\Controllers\SlackRssController; use Cachet\Http\Controllers\StatusPage\ComponentBadgeController; use Cachet\Http\Controllers\StatusPage\StatusBadgeController; use Cachet\Http\Controllers\StatusPage\StatusPageController; @@ -64,6 +65,10 @@ public function register(): void ->middleware('throttle:60,1') ->name('rss'); + $router->get('/slack.rss', SlackRssController::class) + ->middleware('throttle:60,1') + ->name('slack-rss'); + }); $this->registerEmailVerificationRoutes(); diff --git a/tests/Feature/SlackRssTest.php b/tests/Feature/SlackRssTest.php new file mode 100644 index 00000000..e286dbf0 --- /dev/null +++ b/tests/Feature/SlackRssTest.php @@ -0,0 +1,141 @@ +create([ + 'name' => 'Public Incident', + 'visible' => ResourceVisibilityEnum::guest, + ]); + + Update::factory()->forIncident($publicIncident)->create(['message' => 'Public update.']); + + $authenticatedIncident = Incident::factory()->create([ + 'name' => 'Authenticated Incident', + 'visible' => ResourceVisibilityEnum::authenticated, + ]); + + Update::factory()->forIncident($authenticatedIncident)->create(['message' => 'Authenticated update.']); + + $hiddenIncident = Incident::factory()->create([ + 'name' => 'Hidden Incident', + 'visible' => ResourceVisibilityEnum::hidden, + ]); + + Update::factory()->forIncident($hiddenIncident)->create(['message' => 'Hidden update.']); + + $embargoedIncident = Incident::factory()->create([ + 'name' => 'Embargoed Incident', + 'visible' => ResourceVisibilityEnum::guest, + 'published_at' => now()->addWeek(), + ]); + + Update::factory()->forIncident($embargoedIncident)->create(['message' => 'Embargoed update.']); + + $this->get('/status/slack.rss') + ->assertOk() + ->assertSee('Public Incident') + ->assertSee('Public update.') + ->assertDontSee('Authenticated Incident') + ->assertDontSee('Authenticated update.') + ->assertDontSee('Hidden Incident') + ->assertDontSee('Hidden update.') + ->assertDontSee('Embargoed Incident') + ->assertDontSee('Embargoed update.'); +}); + +it('formats incident events for slack', function () { + $reportedAt = Carbon::parse('2026-09-30 09:00:00'); + $identifiedAt = $reportedAt->copy()->addMinute(); + $resolvedAt = $identifiedAt->copy()->addMinute(); + + $component = Component::factory()->create(['name' => 'API']); + $incident = Incident::factory()->create([ + 'name' => 'Elevated error rates', + 'message' => 'We are investigating.', + 'status' => IncidentStatusEnum::fixed, + 'visible' => ResourceVisibilityEnum::guest, + 'created_at' => $reportedAt, + 'updated_at' => $resolvedAt, + ]); + $incident->components()->attach($component); + + $identified = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::identified, + 'message' => 'We identified the cause.', + 'created_at' => $identifiedAt, + 'updated_at' => $identifiedAt, + ]); + $resolved = Update::factory()->forIncident($incident)->create([ + 'status' => IncidentStatusEnum::fixed, + 'message' => 'Service has recovered.', + 'created_at' => $resolvedAt, + 'updated_at' => $resolvedAt, + ]); + + $response = $this->get('/status/slack.rss') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml'); + + $xml = simplexml_load_string($response->getContent()); + + expect($xml)->not->toBeFalse() + ->and((string) $xml->channel->title)->toBe('Cachet incident updates') + ->and((string) $xml->channel->description)->toBe('Latest incident reports and updates from Cachet') + ->and($xml->channel->item)->toHaveCount(3) + ->and((string) $xml->channel->item[0]->title)->toBe('[Fixed] Elevated error rates') + ->and((string) $xml->channel->item[0]->guid)->toEndWith('#update-'.$resolved->id) + ->and((string) $xml->channel->item[0]->description) + ->toContain('Status: Fixed') + ->toContain('Service has recovered.') + ->toContain('Affected components: API') + ->and((string) $xml->channel->item[1]->title)->toBe('[Identified] Elevated error rates') + ->and((string) $xml->channel->item[1]->guid)->toEndWith('#update-'.$identified->id) + ->and((string) $xml->channel->item[2]->title)->toBe('[Reported] Elevated error rates') + ->and((string) $xml->channel->item[2]->guid)->toEndWith('#reported') + ->and((string) $xml->channel->item[2]->description) + ->toContain('Status: Reported') + ->toContain('We are investigating.'); + + $content = $xml->channel->item[0]->children('http://purl.org/rss/1.0/modules/content/'); + + expect((string) $content->encoded)->toBe((string) $xml->channel->item[0]->description); + + $this->get(route('cachet.status-page.incident', $incident)) + ->assertOk() + ->assertSee('id="update-'.$resolved->id.'"', escape: false) + ->assertSee('id="reported"', escape: false); +}); + +it('includes fresh updates to old incidents', function () { + $incident = Incident::factory()->create([ + 'name' => 'Long-running Incident', + 'visible' => ResourceVisibilityEnum::guest, + 'created_at' => now()->subYear(), + ]); + + Update::factory()->forIncident($incident)->create([ + 'message' => 'A fresh update.', + 'created_at' => now(), + ]); + + Incident::factory() + ->count(100) + ->state(new Sequence(fn (Sequence $sequence): array => [ + 'name' => 'Newer Incident '.$sequence->index, + 'visible' => ResourceVisibilityEnum::guest, + 'created_at' => now()->subDays($sequence->index + 1), + ])) + ->create(); + + $this->get('/status/slack.rss') + ->assertOk() + ->assertSee('Long-running Incident') + ->assertSee('A fresh update.'); +}); diff --git a/tests/Unit/Database/DatabaseSeederTest.php b/tests/Unit/Database/DatabaseSeederTest.php index acef7908..d417d38a 100644 --- a/tests/Unit/Database/DatabaseSeederTest.php +++ b/tests/Unit/Database/DatabaseSeederTest.php @@ -2,6 +2,9 @@ use Cachet\Database\Seeders\DatabaseSeeder; use Cachet\Enums\ComponentStatusEnum; +use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; +use Cachet\Models\Incident; use Cachet\Models\Schedule; use Cachet\Models\Update; @@ -38,3 +41,20 @@ ->toContain('For more information, read our latest [blog post]') ->not->toContain('please you can read'); }); + +it('seeds a public documentation incident', function () { + $this->seed(DatabaseSeeder::class); + + $incident = Incident::query() + ->where('name', 'Documentation Search Unavailable') + ->firstOrFail(); + + expect($incident) + ->status->toBe(IncidentStatusEnum::investigating) + ->visible->toBe(ResourceVisibilityEnum::guest) + ->and($incident->components) + ->toHaveCount(1) + ->first()->name->toBe('Cachet Documentation') + ->and($incident->components->first()->pivot->component_status) + ->toBe(ComponentStatusEnum::partial_outage); +});