CluPilotCloud/app/Livewire/Admin/Readiness.php

177 lines
7.1 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Services\Dns\DnsTokenCheck;
use App\Services\Proxmox\VmTemplateCheck;
use App\Services\Stripe\StripeCheck;
use App\Services\Vpn\WireguardEndpointCheck;
use App\Support\OperatingMode;
use App\Support\Readiness as ReadinessRegistry;
use App\Support\Readiness\Check;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The one page an operator setting up a live server goes through top to
* bottom, until nothing blocking is left — App\Support\Readiness rendered,
* with a button on the handful of checks that have to ask a real service
* instead of just reading a field.
*
* Aliased on import (`ReadinessRegistry`) rather than `use App\Support\
* Readiness;` bare: this class is ALSO named Readiness, in the same PHP
* process, and an unaliased reference here would resolve to itself — which
* has no byGroup()/blocking()/isReady() — rather than to the registry.
*
* mount() checks the same gate as Admin\Integrations
* (Gate::any(['hosts.manage', 'secrets.manage'])), because this page is where
* both halves it configures — App\Support\Settings and SecretVault — come
* back together as one list of what is missing.
*/
#[Layout('layouts.admin')]
class Readiness extends Component
{
/**
* Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis.
*
* runCheck() receives its argument from wire:click in the browser — a
* string a stranger could type into devtools just as easily as an
* operator clicking the real button. Without this fixed map, that
* argument would be a way to instantiate and run ANY class through app().
* Four entries: the three on-demand checks Task 9 built (each asks a real
* service instead of only reading a field) plus StripeCheck, which
* already had this exact run() shape from Admin\Integrations' own
* "Testen" button.
*/
private const RUNNABLE = [
'provisioning.dns_token' => DnsTokenCheck::class,
'onboarding.wg_hub' => WireguardEndpointCheck::class,
'provisioning.vm_template' => VmTemplateCheck::class,
'billing.stripe_secret' => StripeCheck::class,
];
/**
* A heartbeat older than this is not shown as fresh even where the check
* itself already says so — a display concern only. Kept in sync with
* App\Support\Readiness\OperationChecks::STALE_AFTER_MINUTES on purpose
* (see the docblock there for why five minutes), not re-derived from it:
* that constant is private, and duplicating the reasoning in a comment
* here is cheaper than making it public for one reader.
*/
private const HEARTBEAT_KEYS = [
'operation.scheduler' => 'heartbeat.scheduler',
'operation.queue_provisioning' => 'heartbeat.queue_provisioning',
];
/**
* The on-demand checks' own answers, keyed by Check::key.
*
* Never pre-filled in mount(): DnsTokenCheck writes a real TXT record to
* the production DNS zone the moment it runs, so nothing here may run
* except in direct response to an operator's own click.
*
* @var array<string, array<string, mixed>>
*/
public array $results = [];
public function mount(): void
{
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
}
public function runCheck(string $key): void
{
$this->authorize('secrets.manage');
// Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein
// Weg, beliebige Klassen aufzurufen.
$class = self::RUNNABLE[$key] ?? null;
if ($class === null) {
return;
}
$this->results[$key] = app($class)->run();
}
/**
* Where an operator goes to actually fix ONE entry.
*
* Not always Admin\Integrations, and not always $check->tab handed
* straight through as its ?tab=: several of the five check groups
* predate the Integrations::TABS redesign and still carry a tab value
* from before it — 'hosts', 'plans', 'datacenters', 'mail', 'templates'
* and 'company' each name a DIFFERENT admin page entirely, and the
* leftover value 'integrations' is the exact bug Task 10's fix round
* already found and corrected for OperationChecks (it does not exist in
* Integrations::TABS, so a link built from it lands nowhere in
* particular — Integrations::mount() silently falls back to its own
* first tab). Rather than repeat that history by forwarding $check->tab
* unchecked, a value only ever becomes a ?tab= when it actually IS one
* of Integrations::TABS; every other known value gets its own real
* route, and anything unrecognised — including the eight checks in
* Onboarding/Provisioning/Delivery still carrying the pre-redesign
* 'integrations' value (Task 8's own deferred note; out of scope for
* this task to rename) — falls back to the Integrations page's own
* default tab rather than a dead link.
*/
public function checkUrl(Check $check): string
{
if (in_array($check->tab, Integrations::TABS, true)) {
return route('admin.integrations', ['tab' => $check->tab]);
}
return match ($check->tab) {
'hosts' => route('admin.hosts'),
'plans' => route('admin.plans'),
'datacenters' => route('admin.datacenters'),
'mail' => route('admin.mail'),
'templates' => route('admin.templates'),
// Company details AND the invoice series both live on
// Admin\Finance — there is no dedicated "company" page.
'company' => route('admin.finance'),
default => route('admin.integrations'),
};
}
public function render()
{
return view('livewire.admin.readiness', [
'groups' => ReadinessRegistry::byGroup(),
'blockingCount' => count(ReadinessRegistry::blocking()),
'ready' => ReadinessRegistry::isReady(),
'mode' => OperatingMode::current(),
'runnable' => array_keys(self::RUNNABLE),
'heartbeats' => collect(self::HEARTBEAT_KEYS)
->map(fn (string $settingsKey) => $this->heartbeatDisplay($settingsKey))
->all(),
]);
}
/**
* The heartbeat's own stored moment, in the operator's zone (R19) — or
* null for "nothing stored" AND for "unreadable", the same forgiving
* either-way OperationChecks::isFresh() already uses. A readiness page
* whose only job is to calmly report what is missing must not itself go
* down over a garbled timestamp (see that method's own docblock for the
* incident this already caused once, inside Readiness::all() itself).
*/
private function heartbeatDisplay(string $settingsKey): ?string
{
$raw = Settings::get($settingsKey);
if (blank($raw)) {
return null;
}
try {
return Carbon::parse($raw)->local()->isoFormat('DD.MM.YYYY HH:mm:ss');
} catch (\Throwable) {
return null;
}
}
}