Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
25
app/Support/Health/Check.php
Normal file
25
app/Support/Health/Check.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
interface Check
|
||||
{
|
||||
/**
|
||||
* Key used for translations and for identifying the check on the command
|
||||
* line. Kebab-case.
|
||||
*/
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* Permission a user must hold to be shown this result, or null when the
|
||||
* result reveals nothing that needs guarding.
|
||||
*
|
||||
* The inbox check counts people waiting for a reply, and who may know that
|
||||
* is the same question as who may read the inbox.
|
||||
*/
|
||||
public function permission(): ?string;
|
||||
|
||||
public function run(): Finding;
|
||||
}
|
||||
51
app/Support/Health/Checks/BrokenImages.php
Normal file
51
app/Support/Health/Checks/BrokenImages.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Images the site is publishing a URL for that no longer exist.
|
||||
*
|
||||
* The API keeps serving the path — it has no reason to doubt it — so the
|
||||
* result is a broken image on the public site and nothing at all in the panel,
|
||||
* where the record still looks complete. A file goes missing when a disk is
|
||||
* changed, a deploy wipes storage, or somebody tidies up by hand.
|
||||
*
|
||||
* Failures recorded by the processor count here too: an image it could not
|
||||
* read is one no visitor will see either.
|
||||
*/
|
||||
class BrokenImages implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'broken-images';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
// Only what has been looked at: an image still queued is not broken,
|
||||
// it is early, and StalledMediaQueue is the check for that.
|
||||
foreach (Media::query()->whereNotNull('processed_at')->get() as $media) {
|
||||
if ($media->error !== null || ! $media->exists()) {
|
||||
$count++;
|
||||
$samples[] = $media->path;
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, Severity::Error, samples: $samples);
|
||||
}
|
||||
}
|
||||
71
app/Support/Health/Checks/BrokenRedirects.php
Normal file
71
app/Support/Health/Checks/BrokenRedirects.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Redirects\RedirectResource;
|
||||
use App\Models\Redirect;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Redirects that make things worse rather than better.
|
||||
*
|
||||
* Two ways a rule can be wrong, and a visitor experiences both as the site
|
||||
* being broken: one that points at its own address, which a browser follows
|
||||
* until it gives up, and one that points at another rule, which costs a round
|
||||
* trip per hop and eventually loops.
|
||||
*
|
||||
* The panel writes these itself when slugs change, so nobody is watching them
|
||||
* — which is exactly why they need watching.
|
||||
*/
|
||||
class BrokenRedirects implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'broken-redirects';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'redirects.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$redirects = Redirect::query()->enabled()->get(['id', 'from', 'to', 'locale']);
|
||||
|
||||
$targets = $redirects->groupBy('locale')->map(
|
||||
fn ($group) => $group->pluck('from')->all(),
|
||||
);
|
||||
|
||||
$broken = [];
|
||||
|
||||
foreach ($redirects as $redirect) {
|
||||
if ($redirect->isCircular()) {
|
||||
$broken[] = "{$redirect->from} → ".__('health.bits.itself');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A rule whose destination is itself a rule: the visitor is sent
|
||||
// somewhere that will send them somewhere else.
|
||||
$sameLocale = $targets[$redirect->locale] ?? [];
|
||||
$everyLocale = $targets[null] ?? [];
|
||||
|
||||
if (in_array($redirect->to, [...$sameLocale, ...$everyLocale], true)) {
|
||||
$broken[] = "{$redirect->from} → {$redirect->to} →…";
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
count($broken),
|
||||
Severity::Error,
|
||||
url: RedirectResource::getUrl('index'),
|
||||
samples: $broken,
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/Support/Health/Checks/ClientsWithoutLogo.php
Normal file
48
app/Support/Health/Checks/ClientsWithoutLogo.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Clients\ClientResource;
|
||||
use App\Models\Client;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Organisations on the wall of logos with no logo.
|
||||
*
|
||||
* The frontend has to render something for them, so it renders their name in
|
||||
* a row of images — a hole in the wall that looks like a mistake, because it
|
||||
* is one.
|
||||
*/
|
||||
class ClientsWithoutLogo implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'clients-without-logo';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'clients.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$clients = Client::query()
|
||||
->public()
|
||||
->whereNull('logo')
|
||||
// Only the ones the wall actually shows: a client with nothing
|
||||
// published is not on it in the first place.
|
||||
->whereHas('projects', fn ($q) => $q->published())
|
||||
->get(['id', 'name']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$clients->count(),
|
||||
url: ClientResource::getUrl('index'),
|
||||
samples: $clients->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/Support/Health/Checks/DuplicateSlugs.php
Normal file
74
app/Support/Health/Checks/DuplicateSlugs.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Two records answering at the same URL in the same language.
|
||||
*
|
||||
* `UniqueTranslatedSlug` prevents this on write, but a slug lives inside a
|
||||
* JSON column, so it can never be a database constraint — which means data
|
||||
* that arrived any other way (a seeder, an import, a manual fix) can still
|
||||
* collide. When it does, the public API resolves the URL to whichever row it
|
||||
* happens to reach first.
|
||||
*
|
||||
* Detected in PHP rather than with a GROUP BY on a JSON path, because that
|
||||
* expression is written differently in every database and these tables hold
|
||||
* hundreds of rows, not millions.
|
||||
*/
|
||||
class DuplicateSlugs implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'duplicate-slugs';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$locales = array_keys((array) config('locales.supported'));
|
||||
$samples = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ([Page::class, Project::class, Call::class] as $model) {
|
||||
/** @var array<int, Model> $records */
|
||||
$records = $model::query()->get(['id', 'slug'])->all();
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$seen = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$slug = $record->getTranslation('slug', $locale, false);
|
||||
|
||||
if (blank($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seen[$slug])) {
|
||||
$count++;
|
||||
$samples[] = "{$slug} ({$locale})";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$slug] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, Severity::Error, samples: $samples);
|
||||
}
|
||||
}
|
||||
50
app/Support/Health/Checks/ExpiredCalls.php
Normal file
50
app/Support/Health/Checks/ExpiredCalls.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Enums\CallStatus;
|
||||
use App\Filament\Resources\Calls\CallResource;
|
||||
use App\Models\Call;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Calls whose closing date has passed but which are still marked open.
|
||||
*
|
||||
* They already drop off the public site — the `open` scope respects the date —
|
||||
* so this is not a visitor-facing fault. It is a housekeeping one: the panel
|
||||
* shows a count of open calls in the sidebar, and a number padded with
|
||||
* openings nobody is filling is a number that stops being read.
|
||||
*/
|
||||
class ExpiredCalls implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'expired-calls';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'calls.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$expired = Call::query()
|
||||
->where('status', CallStatus::Open)
|
||||
->whereNotNull('closes_at')
|
||||
->where('closes_at', '<', now())
|
||||
->get(['id', 'title', 'closes_at']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$expired->count(),
|
||||
url: CallResource::getUrl('index'),
|
||||
samples: $expired
|
||||
->map(fn (Call $call) => $call->getTranslation('title', (string) config('locales.default'), false))
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Support/Health/Checks/ImagesWithoutAlt.php
Normal file
71
app/Support/Health/Checks/ImagesWithoutAlt.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\ImageAttachment;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Images on the public site that nobody described.
|
||||
*
|
||||
* An image with no alt text is invisible to anyone using a screen reader, and
|
||||
* to everybody when it fails to load. The API sends `alt: ""` in that case,
|
||||
* which is the correct way to say "decorative" — so the frontend cannot tell
|
||||
* a deliberate blank from a forgotten one. This can.
|
||||
*
|
||||
* Avatars are not counted: their description is the person's name, and the
|
||||
* panel fills that in itself.
|
||||
*/
|
||||
class ImagesWithoutAlt implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'images-without-alt';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach (Project::query()->published()->whereNotNull('cover_image')->get(['id', 'title', 'cover_image', 'cover_image_alt']) as $project) {
|
||||
if (filled($project->getTranslation('cover_image_alt', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $project->getTranslation('title', $default, false);
|
||||
}
|
||||
|
||||
foreach (Page::query()->published()->whereNotNull('featured_image')->get(['id', 'title', 'featured_image', 'featured_image_alt']) as $page) {
|
||||
if (filled($page->getTranslation('featured_image_alt', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $page->getTranslation('title', $default, false);
|
||||
}
|
||||
|
||||
foreach (ImageAttachment::query()->with('attachable')->get(['id', 'attachable_type', 'attachable_id', 'alt']) as $image) {
|
||||
if (filled($image->getTranslation('alt', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = __('health.bits.gallery_image');
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, samples: array_values(array_unique($samples)));
|
||||
}
|
||||
}
|
||||
69
app/Support/Health/Checks/IncompleteSeo.php
Normal file
69
app/Support/Health/Checks/IncompleteSeo.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Published work with nothing for a search result or a shared link to show.
|
||||
*
|
||||
* Without `meta_description` the frontend has nothing to put in the meta tag
|
||||
* and the snippet gets invented from the body; without a cover there is no
|
||||
* image when the link is pasted into a chat. For a collective whose whole
|
||||
* point is being found and shared, that is the difference between a project
|
||||
* being read and being scrolled past.
|
||||
*/
|
||||
class IncompleteSeo implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'incomplete-seo';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach (Project::query()->published()->get(['id', 'title', 'meta_description', 'cover_image']) as $project) {
|
||||
$missing = [];
|
||||
|
||||
if (blank($project->getTranslation('meta_description', $default, false))) {
|
||||
$missing[] = __('health.bits.description');
|
||||
}
|
||||
|
||||
if (blank($project->cover_image)) {
|
||||
$missing[] = __('health.bits.cover');
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $project->getTranslation('title', $default, false).' ('.implode(', ', $missing).')';
|
||||
}
|
||||
|
||||
foreach (Page::query()->published()->get(['id', 'title', 'meta_description']) as $page) {
|
||||
if (filled($page->getTranslation('meta_description', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $page->getTranslation('title', $default, false).' ('.__('health.bits.description').')';
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, samples: $samples);
|
||||
}
|
||||
}
|
||||
79
app/Support/Health/Checks/MissingSlugs.php
Normal file
79
app/Support/Health/Checks/MissingSlugs.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* A record translated into a language but with no slug in it.
|
||||
*
|
||||
* This is already broken on the public site: the API builds `alternates` from
|
||||
* the slugs, so the language with the missing one is either absent from the
|
||||
* hreflang set or announced at a URL that 404s. Nothing here shows the
|
||||
* problem, which is exactly why it needs a check.
|
||||
*/
|
||||
class MissingSlugs implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'missing-slugs';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$locales = array_keys((array) config('locales.supported'));
|
||||
$samples = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ([Page::class, Project::class, Call::class] as $model) {
|
||||
foreach ($this->publicRecords($model) as $record) {
|
||||
foreach ($locales as $locale) {
|
||||
$hasTitle = filled($record->getTranslation('title', $locale, false));
|
||||
$hasSlug = filled($record->getTranslation('slug', $locale, false));
|
||||
|
||||
if ($hasTitle && ! $hasSlug) {
|
||||
$count++;
|
||||
$samples[] = $record->getTranslation('title', $locale, false)." ({$locale})";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$count,
|
||||
Severity::Error,
|
||||
samples: $samples,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only what the public can reach: a draft with half its slugs filled in is
|
||||
* work in progress, not a fault.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
* @return iterable<int, Model>
|
||||
*/
|
||||
protected function publicRecords(string $model): iterable
|
||||
{
|
||||
$query = $model::query();
|
||||
|
||||
return match ($model) {
|
||||
Call::class => $query->open()->get(['id', 'title', 'slug']),
|
||||
default => $query->published()->get(['id', 'title', 'slug']),
|
||||
};
|
||||
}
|
||||
}
|
||||
67
app/Support/Health/Checks/MissingTranslations.php
Normal file
67
app/Support/Health/Checks/MissingTranslations.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Published content with no title in one of the supported languages.
|
||||
*
|
||||
* The site does not break — the API simply omits that language from the
|
||||
* record's `alternates` — but a visitor browsing in Catalan finds the section
|
||||
* half empty and no one editing in Spanish ever sees it.
|
||||
*/
|
||||
class MissingTranslations implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'missing-translations';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$locales = array_keys((array) config('locales.supported'));
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach ([Page::class, Project::class, Call::class] as $model) {
|
||||
$records = $model::query()
|
||||
->when($model === Call::class, fn (Builder $q) => $q->open())
|
||||
->unless($model === Call::class, fn (Builder $q) => $q->published())
|
||||
->get(['id', 'title']);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$missing = collect($locales)
|
||||
->reject(fn (string $locale) => filled($record->getTranslation('title', $locale, false)));
|
||||
|
||||
if ($missing->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $record->getTranslation('title', (string) config('locales.default'), false)
|
||||
.' ('.$missing->implode(', ').')';
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$count,
|
||||
url: PageResource::getUrl('index'),
|
||||
samples: $samples,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
app/Support/Health/Checks/RedirectsToNowhere.php
Normal file
83
app/Support/Health/Checks/RedirectsToNowhere.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Redirects\RedirectResource;
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\Service;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\SiteUrls;
|
||||
|
||||
/**
|
||||
* Redirects that send a visitor to a page that is not there.
|
||||
*
|
||||
* Worse than the 404 they were meant to prevent: the visitor is bounced, waits
|
||||
* for a second request and lands on the same nothing. It happens when the
|
||||
* record a rule was written for is later unpublished or deleted, which is a
|
||||
* perfectly ordinary thing to do and leaves no trace anywhere else.
|
||||
*/
|
||||
class RedirectsToNowhere implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'redirects-to-nowhere';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'redirects.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$live = $this->publishedPaths();
|
||||
|
||||
$orphans = Redirect::query()
|
||||
->enabled()
|
||||
->get(['id', 'from', 'to'])
|
||||
// Only destinations this application is responsible for: a rule
|
||||
// pointing at somebody else's site is not ours to verify.
|
||||
->filter(fn (Redirect $r) => ! in_array($r->to, $live, true)
|
||||
&& ! str_starts_with($r->to, 'http'));
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$orphans->count(),
|
||||
url: RedirectResource::getUrl('index'),
|
||||
samples: $orphans->map(fn (Redirect $r) => "{$r->from} → {$r->to}")->values()->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every address the public site currently answers at.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function publishedPaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
$sources = [
|
||||
'page' => Page::query()->published()->get(['id', 'slug']),
|
||||
'project' => Project::query()->published()->get(['id', 'slug']),
|
||||
'call' => Call::query()->open()->get(['id', 'slug']),
|
||||
'service' => Service::query()->published()->get(['id', 'slug']),
|
||||
];
|
||||
|
||||
foreach ($sources as $type => $records) {
|
||||
foreach ($records as $record) {
|
||||
foreach (SiteUrls::alternates($record, $type) as $path) {
|
||||
$paths[] = Redirect::normalise($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
50
app/Support/Health/Checks/ServicesWithoutWork.php
Normal file
50
app/Support/Health/Checks/ServicesWithoutWork.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
use App\Models\Service;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Published services with no published work behind them.
|
||||
*
|
||||
* A service that cannot point at something real is a promise, and a landing
|
||||
* page made of promises is the kind visitors have learned to skim past. The
|
||||
* listing already flags this in orange; this is the same fact where somebody
|
||||
* will see it without going looking.
|
||||
*/
|
||||
class ServicesWithoutWork implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'services-without-work';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'services.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
|
||||
$services = Service::query()
|
||||
->published()
|
||||
->whereDoesntHave('projects', fn ($q) => $q->published())
|
||||
->get(['id', 'title']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$services->count(),
|
||||
url: ServiceResource::getUrl('index'),
|
||||
samples: $services
|
||||
->map(fn (Service $s) => $s->getTranslation('title', $default, false))
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
app/Support/Health/Checks/StaleRepositories.php
Normal file
43
app/Support/Health/Checks/StaleRepositories.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Repositories\RepositoryResource;
|
||||
use App\Models\Repository;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Repositories whose sync keeps failing.
|
||||
*
|
||||
* Usually a repository that was renamed, made private or deleted. The public
|
||||
* site keeps showing the last numbers it managed to fetch, which is worse than
|
||||
* showing none: the whole reason for syncing is that live figures are trusted
|
||||
* in a way hand-written ones are not.
|
||||
*/
|
||||
class StaleRepositories implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'stale-repositories';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'repositories.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$failing = Repository::query()->failing()->get(['id', 'full_name']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$failing->count(),
|
||||
url: RepositoryResource::getUrl('index'),
|
||||
samples: $failing->pluck('full_name')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
app/Support/Health/Checks/StaleReviews.php
Normal file
84
app/Support/Health/Checks/StaleReviews.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Support\ContentTypes;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Work that was handed over and never answered.
|
||||
*
|
||||
* The same shape as the unanswered-submissions check, and for the same reason:
|
||||
* somebody is waiting, and nothing else in the panel would ever say so. It is
|
||||
* the failure mode a review step introduces — before it existed, an editor who
|
||||
* could not publish was stuck but knew it; now they are waiting and think
|
||||
* something is happening.
|
||||
*
|
||||
* A review left for a week has stopped being a handover and become a piece of
|
||||
* work nobody owns.
|
||||
*/
|
||||
class StaleReviews implements Check
|
||||
{
|
||||
protected const DAYS = 7;
|
||||
|
||||
/**
|
||||
* The models where publishing is a permission of its own, which is exactly
|
||||
* where a review can exist.
|
||||
*
|
||||
* @var array<int, class-string<Model>>
|
||||
*/
|
||||
protected const REVIEWABLE = [Page::class, Project::class, Service::class];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'stale-reviews';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whoever can publish something is who a stale review is waiting for, so
|
||||
* seeing the count is the same question as being able to end it. Pages
|
||||
* stand in for the three: a role that can publish none of them has no
|
||||
* business being told what is queued.
|
||||
*/
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'pages.publish';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$cutoff = now()->subDays(self::DAYS);
|
||||
$samples = [];
|
||||
$count = 0;
|
||||
|
||||
foreach (self::REVIEWABLE as $model) {
|
||||
$stale = $model::query()
|
||||
->awaitingReview()
|
||||
->where('review_requested_at', '<', $cutoff)
|
||||
->orderBy('review_requested_at')
|
||||
->get();
|
||||
|
||||
$count += $stale->count();
|
||||
|
||||
foreach ($stale as $record) {
|
||||
$samples[] = ContentTypes::title($record)
|
||||
.' ('.$record->review_requested_at->diffForHumans().')';
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$count,
|
||||
Severity::Warning,
|
||||
samples: $samples,
|
||||
);
|
||||
}
|
||||
}
|
||||
81
app/Support/Health/Checks/StaleTranslations.php
Normal file
81
app/Support/Health/Checks/StaleTranslations.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Support\ContentTypes;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\TranslationFreshness;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Translations the original has moved on without.
|
||||
*
|
||||
* The sibling of the missing-translations check, and the one that finds what
|
||||
* nobody would: a language that *has* the text, so every screen in the panel
|
||||
* calls that record finished, while the version visitors read in it says
|
||||
* something the source stopped saying months ago.
|
||||
*
|
||||
* It is the failure that only ever surfaces when somebody who speaks both
|
||||
* languages happens to compare two pages, which is to say almost never.
|
||||
*/
|
||||
class StaleTranslations implements Check
|
||||
{
|
||||
/**
|
||||
* @var array<int, class-string<Model>>
|
||||
*/
|
||||
protected const TRANSLATED = [Page::class, Project::class, Call::class, Service::class];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'stale-translations';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach (self::TRANSLATED as $model) {
|
||||
$records = $model::query()
|
||||
->when($model === Call::class, fn (Builder $q) => $q->open())
|
||||
->unless($model === Call::class, fn (Builder $q) => $q->published())
|
||||
// Loaded here rather than one record at a time: working this
|
||||
// out means reading the change history, and a check that costs
|
||||
// a query per record is one that gets switched off.
|
||||
->with('revisions')
|
||||
->get();
|
||||
|
||||
foreach ($records as $record) {
|
||||
$stale = TranslationFreshness::for($record);
|
||||
|
||||
if ($stale === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
|
||||
// Which languages, and how long they have been drifting —
|
||||
// "since March" is what decides whether this is urgent.
|
||||
$samples[] = ContentTypes::title($record)
|
||||
.' ('.collect($stale)
|
||||
->map(fn ($since, string $locale) => "{$locale}, ".$since->diffForHumans())
|
||||
->implode('; ').')';
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, samples: $samples);
|
||||
}
|
||||
}
|
||||
44
app/Support/Health/Checks/StalledMediaQueue.php
Normal file
44
app/Support/Health/Checks/StalledMediaQueue.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Images that have been waiting to be processed for too long.
|
||||
*
|
||||
* Uploads queue their own work and the scheduler sweeps up every ten minutes,
|
||||
* so anything still pending an hour later means neither is running. The site
|
||||
* keeps working — images serve without dimensions or `srcset` — which is
|
||||
* precisely why nobody would notice.
|
||||
*/
|
||||
class StalledMediaQueue implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'stalled-media';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$stalled = Media::query()
|
||||
->pending()
|
||||
->where('created_at', '<', now()->subHour())
|
||||
->get(['id', 'path']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$stalled->count(),
|
||||
samples: $stalled->pluck('path')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/Support/Health/Checks/TalksWithoutSpeaker.php
Normal file
45
app/Support/Health/Checks/TalksWithoutSpeaker.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Talks\TalkResource;
|
||||
use App\Models\Talk;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Talks nobody is credited with.
|
||||
*
|
||||
* Half the value of writing one down is that it also appears on the profile of
|
||||
* whoever gave it. Without the link it is written once and shown once, which
|
||||
* is most of the work for half the result.
|
||||
*/
|
||||
class TalksWithoutSpeaker implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'talks-without-speaker';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'talks.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$talks = Talk::query()
|
||||
->public()
|
||||
->whereNull('member_id')
|
||||
->get(['id', 'title']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$talks->count(),
|
||||
url: TalkResource::getUrl('index'),
|
||||
samples: $talks->pluck('title')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/Support/Health/Checks/TestimonialsMissingOriginal.php
Normal file
51
app/Support/Health/Checks/TestimonialsMissingOriginal.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||
use App\Models\Testimonial;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Quotes published without the words that were actually said.
|
||||
*
|
||||
* Every testimonial declares the language it was given in. When that language
|
||||
* is the one field left empty, the site is publishing a translation of
|
||||
* something nobody can check — which is the one way a testimonial stops being
|
||||
* evidence and starts being copywriting.
|
||||
*/
|
||||
class TestimonialsMissingOriginal implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'testimonials-missing-original';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'testimonials.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$offenders = Testimonial::query()
|
||||
->public()
|
||||
->get(['id', 'quote', 'original_locale', 'author_name'])
|
||||
->filter(fn (Testimonial $t) => blank($t->getTranslation('quote', $t->original_locale, false)));
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$offenders->count(),
|
||||
Severity::Error,
|
||||
url: TestimonialResource::getUrl('index'),
|
||||
samples: $offenders
|
||||
->map(fn (Testimonial $t) => $t->author_name.' ('.$t->original_locale.')')
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Support/Health/Checks/UnansweredSubmissions.php
Normal file
54
app/Support/Health/Checks/UnansweredSubmissions.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Applications\ApplicationResource;
|
||||
use App\Models\Application;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* People who wrote in over a week ago and have had no answer.
|
||||
*
|
||||
* The only check here that measures somebody else's time rather than ours.
|
||||
* Somebody offered to help a volunteer collective and heard nothing back,
|
||||
* which costs more than any of the other faults on this page.
|
||||
*/
|
||||
class UnansweredSubmissions implements Check
|
||||
{
|
||||
/** Days after which silence stops being a delay and starts being an answer. */
|
||||
protected const GRACE_DAYS = 7;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'unanswered-submissions';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
// Counting them is knowing they exist, and who may know that is the
|
||||
// same question as who may read the inbox.
|
||||
return 'applications.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$waiting = Application::query()
|
||||
->pending()
|
||||
->where('created_at', '<', now()->subDays(self::GRACE_DAYS))
|
||||
->get(['id', 'name', 'created_at']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$waiting->count(),
|
||||
Severity::Error,
|
||||
url: ApplicationResource::getUrl('index'),
|
||||
samples: $waiting
|
||||
->map(fn (Application $a) => $a->name.' · '.$a->created_at->diffForHumans())
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/Support/Health/Checks/UnresolvedMissingUrls.php
Normal file
65
app/Support/Health/Checks/UnresolvedMissingUrls.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\MissingUrls\MissingUrlResource;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Addresses the frontend keeps being asked for and cannot answer.
|
||||
*
|
||||
* Every other check here reasons about what is stored; this one is the only
|
||||
* one that knows what visitors actually did. A 404 is the one fault on the
|
||||
* public site that nobody on this side can discover by looking — it happens to
|
||||
* somebody else, on another machine, and then they leave.
|
||||
*
|
||||
* Counted from two hits upward. A single request is as likely to be a scanner
|
||||
* as a person, and a number that never reaches zero stops being read.
|
||||
*/
|
||||
class UnresolvedMissingUrls implements Check
|
||||
{
|
||||
/**
|
||||
* Below this, an address is a stray request rather than a broken link.
|
||||
*/
|
||||
protected const THRESHOLD = 2;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'missing-urls';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
// The same permission that governs doing something about it.
|
||||
return 'redirects.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$missing = MissingUrl::query()
|
||||
->pending()
|
||||
->where('hits', '>=', self::THRESHOLD)
|
||||
->orderByDesc('hits')
|
||||
->get(['path', 'hits', 'referrer']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$missing->count(),
|
||||
|
||||
// An address our own site links to is a fault in what we publish,
|
||||
// not somebody else's stale bookmark, and it will keep producing
|
||||
// 404s until it is fixed here.
|
||||
$missing->contains(fn (MissingUrl $url) => $url->isInternalReferrer())
|
||||
? Severity::Error
|
||||
: Severity::Warning,
|
||||
|
||||
url: MissingUrlResource::getUrl('index'),
|
||||
samples: $missing->map(fn (MissingUrl $url) => "{$url->path} ({$url->hits})")->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
app/Support/Health/Finding.php
Normal file
66
app/Support/Health/Finding.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
/**
|
||||
* The outcome of one check.
|
||||
*
|
||||
* Carries a few example records on purpose. "12 pages have no description" is
|
||||
* a statistic; "12, starting with *Quiénes somos*" is something somebody can
|
||||
* act on without hunting for the first one.
|
||||
*/
|
||||
final class Finding
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $samples
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $key,
|
||||
public readonly Severity $severity,
|
||||
public readonly int $count = 0,
|
||||
public readonly ?string $url = null,
|
||||
public readonly array $samples = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $samples
|
||||
*/
|
||||
public static function problem(
|
||||
string $key,
|
||||
int $count,
|
||||
Severity $severity = Severity::Warning,
|
||||
?string $url = null,
|
||||
array $samples = [],
|
||||
): self {
|
||||
// A check that found nothing is a pass, whatever severity it would
|
||||
// have reported — so no caller has to remember to say so.
|
||||
if ($count < 1) {
|
||||
return self::ok($key, $url);
|
||||
}
|
||||
|
||||
return new self($key, $severity, $count, $url, array_slice($samples, 0, 3));
|
||||
}
|
||||
|
||||
public static function ok(string $key, ?string $url = null): self
|
||||
{
|
||||
return new self($key, Severity::Ok, 0, $url);
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return __("health.checks.{$this->key}.title");
|
||||
}
|
||||
|
||||
/**
|
||||
* What it means and what to do about it — or, when there is nothing to do,
|
||||
* a plain statement that this one is fine.
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return $this->severity->isProblem()
|
||||
? trans_choice("health.checks.{$this->key}.problem", $this->count, ['count' => $this->count])
|
||||
: __("health.checks.{$this->key}.ok");
|
||||
}
|
||||
}
|
||||
160
app/Support/Health/HealthReport.php
Normal file
160
app/Support/Health/HealthReport.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Health\Checks\BrokenImages;
|
||||
use App\Support\Health\Checks\BrokenRedirects;
|
||||
use App\Support\Health\Checks\ClientsWithoutLogo;
|
||||
use App\Support\Health\Checks\DuplicateSlugs;
|
||||
use App\Support\Health\Checks\ExpiredCalls;
|
||||
use App\Support\Health\Checks\ImagesWithoutAlt;
|
||||
use App\Support\Health\Checks\IncompleteSeo;
|
||||
use App\Support\Health\Checks\MissingSlugs;
|
||||
use App\Support\Health\Checks\MissingTranslations;
|
||||
use App\Support\Health\Checks\RedirectsToNowhere;
|
||||
use App\Support\Health\Checks\ServicesWithoutWork;
|
||||
use App\Support\Health\Checks\StaleRepositories;
|
||||
use App\Support\Health\Checks\StaleReviews;
|
||||
use App\Support\Health\Checks\StaleTranslations;
|
||||
use App\Support\Health\Checks\StalledMediaQueue;
|
||||
use App\Support\Health\Checks\TalksWithoutSpeaker;
|
||||
use App\Support\Health\Checks\TestimonialsMissingOriginal;
|
||||
use App\Support\Health\Checks\UnansweredSubmissions;
|
||||
use App\Support\Health\Checks\UnresolvedMissingUrls;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* What is wrong with the public site, seen from here.
|
||||
*
|
||||
* The reason this page exists is that the frontend lives in another repository
|
||||
* on another host: a missing translation, a slug that resolves in one language
|
||||
* and not another, an image whose file is gone — none of it shows up here,
|
||||
* where the data is edited. It shows up over there, usually late, usually to a
|
||||
* visitor.
|
||||
*/
|
||||
final class HealthReport
|
||||
{
|
||||
/**
|
||||
* @var array<int, class-string<Check>>
|
||||
*/
|
||||
public const CHECKS = [
|
||||
// Things already wrong on the public site come first.
|
||||
MissingSlugs::class,
|
||||
DuplicateSlugs::class,
|
||||
BrokenImages::class,
|
||||
TestimonialsMissingOriginal::class,
|
||||
BrokenRedirects::class,
|
||||
|
||||
// Then things that will bite later.
|
||||
MissingTranslations::class,
|
||||
|
||||
// The same fault one step later: translated, and left behind by
|
||||
// the language it was translated from. Nothing else in the panel
|
||||
// would ever mention it — by every other measure those records
|
||||
// are finished.
|
||||
StaleTranslations::class,
|
||||
ImagesWithoutAlt::class,
|
||||
IncompleteSeo::class,
|
||||
ExpiredCalls::class,
|
||||
UnansweredSubmissions::class,
|
||||
|
||||
// Somebody handed work over and nobody answered. The failure
|
||||
// mode the review step introduces: before it, an editor who
|
||||
// could not publish was stuck and knew it.
|
||||
StaleReviews::class,
|
||||
RedirectsToNowhere::class,
|
||||
|
||||
// What visitors could not reach. Last of the redirect family because
|
||||
// it is the one that arrives on its own: the others are found by
|
||||
// looking at what is stored, this one by somebody hitting a dead link.
|
||||
UnresolvedMissingUrls::class,
|
||||
ServicesWithoutWork::class,
|
||||
ClientsWithoutLogo::class,
|
||||
TalksWithoutSpeaker::class,
|
||||
StalledMediaQueue::class,
|
||||
StaleRepositories::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Every check there is, worst first.
|
||||
*
|
||||
* For the command line, which runs as a deploy rather than as a person:
|
||||
* there is nobody to hide a result from, and a release that skipped the
|
||||
* checks it could not attribute to a user would be worse than useless.
|
||||
*
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function runAll(): Collection
|
||||
{
|
||||
return $this->execute(collect(self::CHECKS)->map(fn (string $check) => app($check)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the checks this user is allowed to see.
|
||||
*
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function runFor(?User $user): Collection
|
||||
{
|
||||
return $this->execute(
|
||||
collect(self::CHECKS)
|
||||
->map(fn (string $check) => app($check))
|
||||
->filter(fn (Check $check) => $this->visibleTo($check, $user)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many checks are failing, for the badge in the sidebar.
|
||||
*
|
||||
* Cached, because this is read on every panel page render and running ten
|
||||
* checks to draw a number would make every page in the panel slower.
|
||||
*/
|
||||
public function problemCount(?User $user): int
|
||||
{
|
||||
return Cache::remember(
|
||||
$this->cacheKey($user),
|
||||
now()->addMinutes(5),
|
||||
fn () => $this->runFor($user)->filter(fn (Finding $f) => $f->severity->isProblem())->count(),
|
||||
);
|
||||
}
|
||||
|
||||
public function forget(?User $user): void
|
||||
{
|
||||
Cache::forget($this->cacheKey($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Check> $checks
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
protected function execute(Collection $checks): Collection
|
||||
{
|
||||
return $checks
|
||||
->map(fn (Check $check) => $check->run())
|
||||
->sortBy([
|
||||
fn (Finding $a, Finding $b) => $a->severity->weight() <=> $b->severity->weight(),
|
||||
fn (Finding $a, Finding $b) => $b->count <=> $a->count,
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
protected function visibleTo(Check $check, ?User $user): bool
|
||||
{
|
||||
$permission = $check->permission();
|
||||
|
||||
if ($permission === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user?->can($permission) ?? false;
|
||||
}
|
||||
|
||||
protected function cacheKey(?User $user): string
|
||||
{
|
||||
return 'health:problems:'.($user?->id ?? 'guest');
|
||||
}
|
||||
}
|
||||
54
app/Support/Health/Severity.php
Normal file
54
app/Support/Health/Severity.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
enum Severity: string
|
||||
{
|
||||
/** Nothing to do. */
|
||||
case Ok = 'ok';
|
||||
|
||||
/** Worth fixing, but the site works. */
|
||||
case Warning = 'warning';
|
||||
|
||||
/** Something on the public site is already wrong. */
|
||||
case Error = 'error';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return __("health.severity.{$this->value}");
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Ok => 'success',
|
||||
self::Warning => 'warning',
|
||||
self::Error => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Ok => 'heroicon-o-check-circle',
|
||||
self::Warning => 'heroicon-o-exclamation-triangle',
|
||||
self::Error => 'heroicon-o-x-circle',
|
||||
};
|
||||
}
|
||||
|
||||
public function isProblem(): bool
|
||||
{
|
||||
return $this !== self::Ok;
|
||||
}
|
||||
|
||||
public function weight(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Error => 0,
|
||||
self::Warning => 1,
|
||||
self::Ok => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user