Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions resources/views/components/incident.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
</div>
<div class="flex flex-col divide-y divide-zinc-900/10 px-4 dark:divide-white/15 sm:px-6">
@foreach ($incident->updates as $update)
<div data-component="incident-update" data-update-id="{{ $update->getKey() }}" class="relative py-5 sm:last:pb-6" x-data="{ timestamp: new Date(@js($update->created_at)) }">
<div @if($headingLevel === 1) id="update-{{ $update->getKey() }}" @endif data-component="incident-update" data-update-id="{{ $update->getKey() }}" class="relative py-5 sm:last:pb-6" x-data="{ timestamp: new Date(@js($update->created_at)) }">
<x-cachet::incident-update-status :status="$update->status" />
@if ($headingLevel === 1)
<h2 class="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-base">{{ $update->status->getLabel() }}</h2>
Expand All @@ -80,7 +80,7 @@ class="text-zinc-400 transition hover:text-zinc-700 dark:text-zinc-500 dark:hove
<div data-slot="message" class="prose-sm md:prose prose-zinc dark:prose-invert prose-a:text-accent-content prose-a:underline prose-p:leading-normal mt-2">{!! $update->formattedMessage() !!}</div>
</div>
@endforeach
<div data-component="incident-update" data-update-id="reported" class="relative py-5 sm:last:pb-6" x-data="{ timestamp: new Date(@js($incident->timestamp)) }">
<div @if($headingLevel === 1) id="reported" @endif data-component="incident-update" data-update-id="reported" class="relative py-5 sm:last:pb-6" x-data="{ timestamp: new Date(@js($incident->timestamp)) }">
@php($reportStatus = $incident->updates->isEmpty() ? $incident->status : null)
<x-cachet::incident-update-status :status="$reportStatus ?? IncidentStatusEnum::unknown" />
@if ($headingLevel === 1)
Expand Down
39 changes: 39 additions & 0 deletions resources/views/slack-rss.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?=
'<?xml version="1.0" encoding="utf-8"?>'.PHP_EOL
?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>{{ $statusPageName }} incident updates</title>
<link>{{ route('cachet.status-page') }}</link>
<description>Latest incident reports and updates from {{ $statusPageName }}</description>
<lastBuildDate>{{ now()->toRssString() }}</lastBuildDate>
<docs>https://validator.w3.org/feed/docs/rss2.html</docs>
<generator>Cachet</generator>
@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(']]>', ']]]]><![CDATA[>', '['.$status.'] '.$incident->name);
$description = $message.'<br/><br/><strong>Status:</strong> '.e($status);

if ($incident->components->isNotEmpty()) {
$components = $incident->components->pluck('name')->map(fn (string $name): string => e($name))->join(', ');
$description .= '<br/><strong>Affected components:</strong> '.$components;
}

$description = str_replace(']]>', ']]]]><![CDATA[>', $description);
@endphp
<item>
<title><![CDATA[{!! $title !!}]]></title>
<link>{{ $url }}</link>
<guid>{{ $url }}</guid>
<pubDate>{{ $item['publishedAt']->toRssString() }}</pubDate>
<description><![CDATA[{!! $description !!}]]></description>
<content:encoded><![CDATA[{!! $description !!}]]></content:encoded>
</item>
@endforeach
</channel>
</rss>
135 changes: 135 additions & 0 deletions src/Http/Controllers/SlackRssController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace Cachet\Http\Controllers;

use Cachet\Models\Incident;
use Cachet\Models\Update;
use Cachet\Settings\AppSettings;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;

class SlackRssController
{
/**
* The maximum number of incident events included in the feed.
*/
private const MAX_ITEMS = 100;

/**
* How long the rendered feed is cached for, in seconds.
*/
private const CACHE_TTL = 60;

/**
* Returns the incident event feed consumed by Slack.
*/
public function __invoke(Request $request, AppSettings $appSettings): Response
{
$feed = Cache::remember('cachet::slack-rss-feed', self::CACHE_TTL, function () use ($appSettings) {
return view('cachet::slack-rss', [
'statusPageName' => $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<int, Incident>
*/
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<Incident>
*/
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<int, Incident> $incidents
* @return list<array{incident: Incident, update: ?Update, publishedAt: Carbon}>
*/
private function items(Collection $incidents): array
{
/** @var list<array{incident: Incident, update: ?Update, publishedAt: Carbon}> $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);
}
}
4 changes: 3 additions & 1 deletion src/Models/Incident.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/Models/Update.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/PendingRouteRegistration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading