182 lines
7.4 KiB
PHP
182 lines
7.4 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,
|
|
];
|
|
|
|
/**
|
|
* Check::key → the App\Support\Settings row it is written under.
|
|
*
|
|
* Display only, and carries no threshold of its own: whether a heartbeat
|
|
* counts as fresh is decided exactly once, by
|
|
* App\Support\Readiness\OperationChecks::isFresh(), and shows up here
|
|
* only as the check's own `satisfied`/severity — this map exists solely
|
|
* so heartbeatDisplay() knows which stored value to format for which
|
|
* check. (A previous version of this comment claimed a staleness
|
|
* threshold was kept in sync with OperationChecks::STALE_AFTER_MINUTES —
|
|
* there is no such value here to keep in sync. CLAUDE.md's R19 names this
|
|
* exact shape of mistake — a call that reads as an assurance and is not
|
|
* one — as worse than no comment at all, because it stops the next
|
|
* reader from checking for themselves.)
|
|
*/
|
|
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: six of the nine `tab` values used across
|
|
* the five check groups name a DIFFERENT admin page entirely — 'hosts',
|
|
* 'plans', 'datacenters', 'mail', 'templates' and 'company' are pages of
|
|
* their own, not Integrations tabs, and forwarding them as `?tab=` would
|
|
* either 404 or (since Integrations::mount() falls back silently to its
|
|
* own first tab for an unrecognised value) land quietly on the wrong
|
|
* section. Every OTHER `tab` value that exists in the five check group
|
|
* files today (checked at the source, Fix-Runde) really IS one of
|
|
* Integrations::TABS — nine of them were not, until that same Fix-Runde
|
|
* corrected them there rather than papering over it here (see
|
|
* BillingChecks/OnboardingChecks/ProvisioningChecks/DeliveryChecks). The
|
|
* `default` arm below is therefore a pure safety net for a value nobody
|
|
* currently produces, not a route any check relies on — kept so a future
|
|
* typo degrades to the Integrations landing tab instead of an
|
|
* UnhandledMatchError taking the whole page down.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
}
|