From de6821b53e4af7d32cabf3d9e40187c1c38d4e3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 06:05:40 +0200 Subject: [PATCH] Move the console off /admin, give the status page its own address, and measure monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the owner asked for, and one the review found underneath them. The console leaves /admin. It has its own route file now, registered before routes/web.php because once it has a hostname to itself it sits at the ROOT of that host — where `/` and `/settings` also exist for the portal, and the first matching route wins. Where it mounts is one decision in one place: `/` on the console hostname where it has one to itself, `/admin` on any host otherwise. Names stay admin.* in both modes, so nothing that builds a URL has to know. Exclusivity is its own switch, not a consequence of having a hostname. The first attempt made "this host is the console" follow from ADMIN_HOSTS, and a development machine lists its own IP there so the console works without DNS — which took the public site and the portal off that machine entirely. The switch also registers the console on every listed hostname, canonical last, so the alternates that exist as recovery paths keep working. The status page moves out of /legal, where it sat beside the imprint and the terms. Nothing about the current health of the platform is a legal document. It is a real page now: portal, instances, provisioning and backups, each derived from records, aggregate only, and a component with no signal reports "unknown" rather than "operational". That last rule is what exposed the real bug. monitoring_targets.status was written once — 'up', at provisioning — and nothing ever updated it. Both the console's notices and the new public page read it, so an outage would have been published as healthy indefinitely. There is a sync job now, on a five-minute schedule, and a checked_at column so a verdict can go stale instead of standing forever. The monitoring contract had to grow a third state for that to be honest. isHealthy() answers true when no monitoring is configured and false when the monitor is unreachable; recording that boolean would have published either a fleet-wide all-clear nobody measured or a fleet-wide outage that was really one broken monitor. health() returns null for both, the recorder leaves the old verdict to go stale, and isHealthy() keeps its forgiving semantics for the one caller that wants them — the provisioning acceptance check, which must not fail a delivery because monitoring is not set up. Backups are counted from the instances that need protecting rather than from the backup rows that exist, so an instance with no schedule at all cannot be missing from the arithmetic that declares the estate protected. Also: the update panel polls itself, offers the button only when there is something to install, and opens the log while a run is in progress. Co-Authored-By: Claude Opus 5 --- app/Http/Controllers/StatusController.php | 194 ++++++++++++++++++ app/Http/Middleware/RestrictAdminHost.php | 7 +- app/Livewire/Admin/Overview.php | 9 +- app/Models/MonitoringTarget.php | 11 +- .../Jobs/SyncMonitoringStatus.php | 74 +++++++ .../Steps/Customer/RegisterMonitoring.php | 4 +- .../Monitoring/FakeMonitoringClient.php | 5 + .../Monitoring/HttpMonitoringClient.php | 17 +- app/Services/Monitoring/MonitoringClient.php | 19 +- app/Support/AdminArea.php | 29 ++- config/admin_access.php | 10 + ...0_record_when_monitoring_last_answered.php | 34 +++ lang/de/admin_settings.php | 7 + lang/de/status.php | 40 ++++ lang/en/admin_settings.php | 7 + lang/en/status.php | 40 ++++ resources/views/landing.blade.php | 2 +- .../views/livewire/admin/settings.blade.php | 45 +++- resources/views/status.blade.php | 134 ++++++++++++ routes/admin.php | 39 ++++ routes/console.php | 6 + routes/web.php | 82 +++++--- .../Admin/ConsoleHostSeparationTest.php | 122 +++++++++++ tests/Feature/MonitoringStatusSyncTest.php | 107 ++++++++++ tests/Feature/WelcomeTest.php | 41 +++- 25 files changed, 1034 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/StatusController.php create mode 100644 app/Provisioning/Jobs/SyncMonitoringStatus.php create mode 100644 database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php create mode 100644 lang/de/status.php create mode 100644 lang/en/status.php create mode 100644 resources/views/status.blade.php create mode 100644 routes/admin.php create mode 100644 tests/Feature/Admin/ConsoleHostSeparationTest.php create mode 100644 tests/Feature/MonitoringStatusSyncTest.php diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php new file mode 100644 index 0000000..f580927 --- /dev/null +++ b/app/Http/Controllers/StatusController.php @@ -0,0 +1,194 @@ +portal(), + $this->instances(), + $this->provisioning(), + $this->backups(), + ]; + + // The worst individual state is the state of the whole thing. Averaging + // it would let one outage disappear behind three healthy components. + $overall = match (true) { + in_array('down', array_column($components, 'state'), true) => 'down', + in_array('degraded', array_column($components, 'state'), true) => 'degraded', + in_array('unknown', array_column($components, 'state'), true) => 'unknown', + default => 'operational', + }; + + return view('status', [ + 'components' => $components, + 'overall' => $overall, + 'checkedAt' => Carbon::now(), + ]); + } + + /** + * The portal and the website. + * + * Answering this request is the measurement. There is no honest way for a + * page to report that the server serving it is down. + * + * @return array + */ + private function portal(): array + { + return [ + 'key' => 'portal', + 'state' => 'operational', + 'detail' => null, + ]; + } + + /** + * Customer instances, from what monitoring last saw. + * + * @return array + */ + private function instances(): array + { + $total = Instance::query()->where('status', 'active')->count(); + + if ($total === 0) { + return ['key' => 'instances', 'state' => 'operational', 'detail' => null]; + } + + // Only targets belonging to instances that are actually in service. A + // healthy check on a decommissioned instance says nothing about a live + // one, and counting it would let coverage look complete when it is not. + $onActive = fn ($query) => $query->whereHas( + 'instance', + fn ($instance) => $instance->where('status', 'active'), + ); + + // Only verdicts the sync job has actually refreshed. A row that has + // never been checked, or was last checked long enough ago that the + // answer means nothing, is not evidence of health — and this column was + // for a long time exactly that: written 'up' at provisioning and never + // touched again. + $fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES); + $checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh); + + $watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id'); + $down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id'); + + // Down first, then coverage. An instance nobody is watching is not a + // healthy instance — it is an unanswered question, and reporting the + // absence of a check as the absence of a problem is the one thing this + // page must never do. + return [ + 'key' => 'instances', + 'state' => match (true) { + $down > 0 && $down >= $watched => 'down', + $down > 0 => 'degraded', + $watched < $total => 'unknown', + default => 'operational', + }, + 'detail' => match (true) { + $down > 0 => ['down' => $down, 'total' => $watched], + $watched < $total => ['down' => $total - $watched, 'total' => $total], + default => null, + }, + ]; + } + + /** + * Whether new instances are being delivered. + * + * @return array + */ + private function provisioning(): array + { + $since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS); + + $failed = ProvisioningRun::query() + ->where('status', ProvisioningRun::STATUS_FAILED) + ->where('updated_at', '>=', $since) + ->count(); + + return [ + 'key' => 'provisioning', + 'state' => $failed === 0 ? 'operational' : 'degraded', + 'detail' => $failed > 0 ? ['failed' => $failed] : null, + ]; + } + + /** + * Whether the last backup of every instance is recent. + * + * @return array + */ + private function backups(): array + { + // Counted from the INSTANCES that need protecting, not from the backup + // rows that happen to exist. An active instance with no schedule at all + // has no row — so counting rows would leave it out of the arithmetic + // entirely and report the estate as protected because the backups that + // do exist are fine. + $total = Instance::query()->where('status', 'active')->count(); + + if ($total === 0) { + return ['key' => 'backups', 'state' => 'operational', 'detail' => null]; + } + + $fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS); + + $protected = Instance::query() + ->where('status', 'active') + ->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh)) + ->count(); + + $unprotected = $total - $protected; + + return [ + 'key' => 'backups', + 'state' => match (true) { + $unprotected === 0 => 'operational', + $unprotected >= $total => 'down', + default => 'degraded', + }, + 'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null, + ]; + } +} diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index c9d13a0..a6a1936 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -64,8 +64,11 @@ class RestrictAdminHost abort(404); } - // The portal or the public site, reached through the console's hostname. - if ($onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) { + // The portal or the public site, reached through the console's + // hostname. Only once exclusivity is switched on: a machine that lists + // its own IP in ADMIN_HOSTS so the console is reachable without DNS + // still has to serve the portal from that same address. + if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) { abort(404); } diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 1b570b6..2ab43b9 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -277,8 +277,13 @@ class Overview extends Component // Distinct instances, not targets: one instance can be watched by // several checks, and counting checks would report three outages where - // one machine is down. - $down = MonitoringTarget::query()->where('status', '!=', 'up')->distinct()->count('instance_id'); + // one machine is down. And only verdicts the sync job has refreshed — + // an unchecked row is not a healthy one, nor a failing one. + $down = MonitoringTarget::query() + ->where('status', 'down') + ->where('checked_at', '>=', Carbon::now()->subMinutes(20)) + ->distinct() + ->count('instance_id'); if ($down > 0) { $notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; } diff --git a/app/Models/MonitoringTarget.php b/app/Models/MonitoringTarget.php index 742bd75..de4644f 100644 --- a/app/Models/MonitoringTarget.php +++ b/app/Models/MonitoringTarget.php @@ -7,7 +7,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MonitoringTarget extends Model { - protected $fillable = ['instance_id', 'external_id', 'url', 'status']; + protected $fillable = ['instance_id', 'external_id', 'url', 'status', 'checked_at']; + + protected function casts(): array + { + // Without the cast the freshness comparison happens between a string + // and a Carbon instance, and without checked_at being fillable the sync + // job's update silently drops it — leaving every verdict permanently + // stale, which reads on the status page as "not monitored". + return ['checked_at' => 'datetime']; + } public function instance(): BelongsTo { diff --git a/app/Provisioning/Jobs/SyncMonitoringStatus.php b/app/Provisioning/Jobs/SyncMonitoringStatus.php new file mode 100644 index 0000000..4eb5b29 --- /dev/null +++ b/app/Provisioning/Jobs/SyncMonitoringStatus.php @@ -0,0 +1,74 @@ +whereHas('instance', fn ($instance) => $instance->where('status', 'active')) + ->chunkById(100, function ($targets) use ($monitoring) { + foreach ($targets as $target) { + $this->refresh($target, $monitoring); + } + }); + } + + private function refresh(MonitoringTarget $target, MonitoringClient $monitoring): void + { + try { + // health(), not isHealthy(): the boolean answer folds "no monitoring + // configured" into true and "monitor unreachable" into false, and + // recording either as a measurement publishes a fleet-wide lie. + $healthy = $monitoring->health($target->external_id); + } catch (Throwable $e) { + // The monitoring service being unreachable says nothing about the + // instance. Recording 'down' here would raise an alarm about the + // wrong system; recording 'up' would hide a real one. Leave the + // previous verdict and let it go stale. + Log::warning('Monitoring did not answer for a target', [ + 'external_id' => $target->external_id, + 'exception' => $e->getMessage(), + ]); + + return; + } + + if ($healthy === null) { + // No answer. The previous verdict stays and is allowed to go stale, + // which readers show as "not monitored" rather than as health. + return; + } + + $target->update([ + 'status' => $healthy ? 'up' : 'down', + 'checked_at' => now(), + ]); + } +} diff --git a/app/Provisioning/Steps/Customer/RegisterMonitoring.php b/app/Provisioning/Steps/Customer/RegisterMonitoring.php index ce76e33..bf579a2 100644 --- a/app/Provisioning/Steps/Customer/RegisterMonitoring.php +++ b/app/Provisioning/Steps/Customer/RegisterMonitoring.php @@ -67,7 +67,9 @@ class RegisterMonitoring extends CustomerStep // Local row BEFORE the breadcrumb (the short-circuit guard). $instance->monitoringTargets()->firstOrCreate( ['external_id' => $targetId], - ['url' => $url, 'status' => 'up'], + // 'unknown', not 'up': registering a check is not the same as + // passing one, and the sync job is what turns it into a verdict. + ['url' => $url, 'status' => 'unknown'], ); $this->recordResource($run, $instance->host, 'monitoring_target_id', $targetId); diff --git a/app/Services/Monitoring/FakeMonitoringClient.php b/app/Services/Monitoring/FakeMonitoringClient.php index e86334b..f2e9432 100644 --- a/app/Services/Monitoring/FakeMonitoringClient.php +++ b/app/Services/Monitoring/FakeMonitoringClient.php @@ -36,6 +36,11 @@ class FakeMonitoringClient implements MonitoringClient } public function isHealthy(string $externalId): bool + { + return $this->health($externalId) ?? true; + } + + public function health(string $externalId): ?bool { return $this->healthy; } diff --git a/app/Services/Monitoring/HttpMonitoringClient.php b/app/Services/Monitoring/HttpMonitoringClient.php index 37ce53a..13ccb92 100644 --- a/app/Services/Monitoring/HttpMonitoringClient.php +++ b/app/Services/Monitoring/HttpMonitoringClient.php @@ -35,20 +35,29 @@ class HttpMonitoringClient implements MonitoringClient } public function isHealthy(string $externalId): bool + { + // Unknown counts as healthy HERE, and only here: this is the + // provisioning acceptance check, which must not fail a delivery because + // monitoring is not set up. + return $this->health($externalId) ?? true; + } + + public function health(string $externalId): ?bool { $endpoint = (string) config('services.monitoring.url'); + if (blank($endpoint)) { - return true; // no monitoring service configured + return null; // nothing is watching, which is not the same as healthy } try { $monitor = Http::withToken((string) config('services.monitoring.token')) ->get(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw()->json('monitor', []); - - return ($monitor['status'] ?? '') === 'up'; } catch (\Throwable) { - return false; + return null; // the monitor is unreachable, which says nothing about the instance } + + return ($monitor['status'] ?? '') === 'up'; } public function deregisterTarget(string $externalId): void diff --git a/app/Services/Monitoring/MonitoringClient.php b/app/Services/Monitoring/MonitoringClient.php index b48520c..f693794 100644 --- a/app/Services/Monitoring/MonitoringClient.php +++ b/app/Services/Monitoring/MonitoringClient.php @@ -9,6 +9,23 @@ interface MonitoringClient public function deregisterTarget(string $externalId): void; - /** Current health of a target as reported by the monitoring provider. */ + /** + * Current health of a target as reported by the monitoring provider. + * + * A yes/no answer for the provisioning acceptance check, which wants a + * verdict and treats "cannot tell" as a pass. Anything RECORDING health + * must use health() instead. + */ public function isHealthy(string $externalId): bool; + + /** + * Current health, or null when the provider could not answer. + * + * The three states are genuinely different and collapsing them is how a + * status page lies: with no monitoring configured isHealthy() answers true, + * and with the monitoring service unreachable it answers false — so + * recording that boolean would publish either a fleet-wide all-clear nobody + * measured, or a fleet-wide outage that is really one broken monitor. + */ + public function health(string $externalId): ?bool; } diff --git a/app/Support/AdminArea.php b/app/Support/AdminArea.php index 6354c60..92bc94d 100644 --- a/app/Support/AdminArea.php +++ b/app/Support/AdminArea.php @@ -60,6 +60,24 @@ final class AdminArea ))); } + /** + * May ONLY the console answer on the console's hostname? + * + * Separate from being host-bound, and off by default, because the two are + * not the same thing. A development machine lists its own IP and localhost + * in ADMIN_HOSTS so the console is reachable without DNS — and on that + * machine the portal and the public site have to keep working. Turning + * exclusivity on there would 404 everything that is not the console, which + * is exactly what it did the first time this shipped. + * + * It belongs with the routing change that moves the console to the root of + * its own hostname: until then a console host legitimately serves both. + */ + public static function isExclusive(): bool + { + return self::isHostBound() && (bool) config('admin_access.exclusive', false); + } + /** Whether the console is bound to a hostname at all. */ public static function isHostBound(): bool { @@ -75,7 +93,10 @@ final class AdminArea */ public static function isConsole(Request $request): bool { - return self::isHostBound() + // Keyed on exclusivity, not on merely having a hostname: where the + // console shares its host with the portal it still lives under /admin, + // and "every request to this host is the console" would be false. + return self::isExclusive() ? self::covers($request->getHost()) : $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*'); } @@ -89,12 +110,12 @@ final class AdminArea /** * The path prefix console routes are registered under. * - * Empty when host-bound (the console is at the root of its own hostname), - * `admin` otherwise. + * Empty when the console has its hostname to itself — it is then the root + * of that host. `admin` otherwise. */ public static function prefix(): string { - return self::isHostBound() ? '' : self::FALLBACK_PREFIX; + return self::isExclusive() ? '' : self::FALLBACK_PREFIX; } /** Where the console's own front page lives, as a path. */ diff --git a/config/admin_access.php b/config/admin_access.php index c113118..67bddf6 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -23,6 +23,16 @@ return [ explode(',', (string) env('ADMIN_HOSTS', '')) ))), + /* + | Whether the console's hostname serves ONLY the console. + | + | Off by default, and deliberately separate from having a hostname at all: a + | development machine lists its own IP in ADMIN_HOSTS so the console works + | without DNS, and that same address still has to serve the portal. Switch + | it on only where the console really has a hostname to itself. + */ + 'exclusive' => (bool) env('ADMIN_HOST_EXCLUSIVE', false), + /* | Key for VPN configs stored at rest (32 bytes, base64). Separate from | APP_KEY on purpose — see App\Services\Wireguard\ConfigVault. Empty means diff --git a/database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php b/database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php new file mode 100644 index 0000000..87901e3 --- /dev/null +++ b/database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php @@ -0,0 +1,34 @@ +timestamp('checked_at')->nullable()->after('status'); + }); + + // Every existing row is an unverified claim. Clearing it is the point: + // "we have not looked" must not keep reading as "it is fine". + DB::table('monitoring_targets')->update(['status' => 'unknown', 'checked_at' => null]); + } + + public function down(): void + { + Schema::table('monitoring_targets', fn (Blueprint $table) => $table->dropColumn('checked_at')); + } +}; diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index bc76799..3fe24c4 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -96,4 +96,11 @@ return [ 'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.', 'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.', ], + + 'update_last_run' => 'Letzter Lauf: :state, :when.', + 'update_state' => [ + 'succeeded' => 'erfolgreich', + 'failed' => 'fehlgeschlagen', + 'running' => 'läuft', + ], ]; diff --git a/lang/de/status.php b/lang/de/status.php new file mode 100644 index 0000000..1ef0819 --- /dev/null +++ b/lang/de/status.php @@ -0,0 +1,40 @@ + [ + 'operational' => 'Alle Dienste in Betrieb', + 'degraded' => 'Ein Dienst ist beeinträchtigt', + 'down' => 'Ein Dienst ist gestört', + // Not "in Betrieb": nothing is being watched, and reporting the absence + // of a check as the absence of a problem is the one thing a status page + // must never do. + 'unknown' => 'Nicht vollständig überwacht', + ], + + 'state' => [ + 'operational' => 'In Betrieb', + 'degraded' => 'Beeinträchtigt', + 'down' => 'Gestört', + 'unknown' => 'Unbekannt', + ], + + 'component' => [ + 'portal' => 'Website & Kundenportal', + 'instances' => 'Cloud-Instanzen', + 'provisioning' => 'Bereitstellung neuer Instanzen', + 'backups' => 'Sicherungen', + ], + + 'about' => [ + 'portal' => 'Erreichbarkeit von www.clupilot.com und dem Kundenbereich.', + 'instances' => 'Erreichbarkeit der betriebenen Kundeninstanzen.', + 'provisioning' => 'Neue Bestellungen werden ohne Fehler ausgeliefert.', + 'backups' => 'Jede Instanz hat eine Sicherung aus den letzten 48 Stunden.', + ], + + 'detail' => [ + 'instances' => ':down von :total Instanzen ohne positives Signal.', + 'provisioning' => ':failed fehlgeschlagene Bereitstellung(en) in den letzten 24 Stunden.', + 'backups' => ':stale von :total Instanzen ohne aktuelle Sicherung.', + ], +]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index bb21bac..21d6a59 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -96,4 +96,11 @@ return [ 'detached_no_release' => 'The checkout is on no branch and pinned to no release.', 'update_failed' => 'The update failed (code :code). See the log.', ], + + 'update_last_run' => 'Last run: :state, :when.', + 'update_state' => [ + 'succeeded' => 'succeeded', + 'failed' => 'failed', + 'running' => 'running', + ], ]; diff --git a/lang/en/status.php b/lang/en/status.php new file mode 100644 index 0000000..c13941c --- /dev/null +++ b/lang/en/status.php @@ -0,0 +1,40 @@ + [ + 'operational' => 'All services operational', + 'degraded' => 'One service is degraded', + 'down' => 'One service is down', + // Not "operational": nothing is being watched, and reporting the + // absence of a check as the absence of a problem is the one thing a + // status page must never do. + 'unknown' => 'Not fully monitored', + ], + + 'state' => [ + 'operational' => 'Operational', + 'degraded' => 'Degraded', + 'down' => 'Down', + 'unknown' => 'Unknown', + ], + + 'component' => [ + 'portal' => 'Website & customer portal', + 'instances' => 'Cloud instances', + 'provisioning' => 'Provisioning of new instances', + 'backups' => 'Backups', + ], + + 'about' => [ + 'portal' => 'Availability of www.clupilot.com and the customer area.', + 'instances' => 'Availability of the customer instances we operate.', + 'provisioning' => 'New orders are being delivered without failures.', + 'backups' => 'Every instance has a backup from the last 48 hours.', + ], + + 'detail' => [ + 'instances' => ':down of :total instances without a positive signal.', + 'provisioning' => ':failed failed provisioning run(s) in the last 24 hours.', + 'backups' => ':stale of :total instances without a current backup.', + ], +]; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index fb51b03..ba04b09 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -863,7 +863,7 @@ Impressum Datenschutz AGB - Status + Status Anmelden diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 1c75260..bdc66f1 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -6,7 +6,11 @@ {{-- Version & update --}} @if ($canManageSite) -
+ {{-- Polls itself: an operator watching an update run should not have to + reload the page to find out whether it finished. Fast while + something is happening, slow when nothing is. --}} +
@@ -50,13 +54,25 @@
- {{-- Bound, not @disabled(): the directive compiles to inline PHP - inside the component tag, and Blade then no longer - recognises the tag as a component at all. --}} - - {{ __('admin_settings.update_now') }} - + {{-- Offered only when there is something to install. Bound, not + @disabled(): the directive compiles to inline PHP inside the + component tag, and Blade then stops seeing a component. --}} +
+ @if ($update['running']) + + + {{ __('admin_settings.update_running') }} + + @elseif ($update['available']) + + {{ __('admin_settings.update_now') }} + + @else + + {{ $update['behind'] === 0 ? __('admin_settings.update_current') : __('admin_settings.update_unknown') }} + + @endif +
@if (! $update['agent_seen']) @@ -65,8 +81,19 @@ {{ $update['last_error'] }} @endif + @if ($update['last_state'] !== null && ! $update['running']) +

+ {{ __('admin_settings.update_last_run', [ + 'state' => __('admin_settings.update_state.'.$update['last_state']), + 'when' => $update['last_finished_at']?->diffForHumans() ?? '—', + ]) }} +

+ @endif + + {{-- Open while it runs: "läuft gerade" on its own tells an operator + nothing about whether it is progressing or wedged. --}} @if ($updateLog) -
+
{{ __('admin_settings.update_log') }}
{{ $updateLog }}
diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php new file mode 100644 index 0000000..f78d545 --- /dev/null +++ b/resources/views/status.blade.php @@ -0,0 +1,134 @@ + + + + + +Status — CluPilot Cloud + + + + + +@verbatim + +@endverbatim + + +
+
+ CluPilot Cloud +

Betriebsstatus

+ +
+ + {{ __('status.overall.'.$overall) }} +
+
+ +
+ @foreach ($components as $c) +
+
+ {{ __('status.component.'.$c['key']) }} + + @if ($c['detail'] !== null) + {{ __('status.detail.'.$c['key'], $c['detail']) }} + @else + {{ __('status.about.'.$c['key']) }} + @endif + +
+ {{ __('status.state.'.$c['state']) }} +
+ @endforeach +
+ +

Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr

+ +

+ Diese Seite wird bei jedem Aufruf neu ermittelt — aus Überwachung, Sicherungsprotokoll und + Bereitstellungsläufen. Wo nichts überwacht wird, steht das auch so da und nicht „in Betrieb“. + Eine Störung, die Sie hier nicht sehen, melden Sie bitte an + office@clupilot.com — Antwort am selben Werktag. +

+ + +
+ + diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..65a0bb2 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,39 @@ +name('overview'); +Route::get('/customers', Admin\Customers::class)->name('customers'); +Route::get('/instances', Admin\Instances::class)->name('instances'); +Route::get('/hosts', Admin\Hosts::class)->name('hosts'); +Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); +Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); +Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); +Route::get('/plans', Admin\Plans::class)->name('plans'); +Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); +// POST so the identity change is CSRF-protected (a GET could be forced cross-site). +Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); +Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); +Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); +Route::get('/vpn', Admin\Vpn::class)->name('vpn'); +Route::get('/revenue', Admin\Revenue::class)->name('revenue'); +Route::get('/settings', Admin\Settings::class)->name('settings'); diff --git a/routes/console.php b/routes/console.php index f76c9d9..1ee7ae2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -24,6 +24,12 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers) // Sample network counters and enforce the monthly allowance. Runs on the // provisioning queue, which is where the Proxmox credentials are usable. +// Turns the monitoring column from a claim into a measurement. Without it the +// console and the public status page report every instance healthy forever. +Schedule::job(new \App\Provisioning\Jobs\SyncMonitoringStatus) + ->everyFiveMinutes() + ->withoutOverlapping(); + Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic) ->everyFifteenMinutes() ->name('traffic-collect'); diff --git a/routes/web.php b/routes/web.php index cbc4cd8..f2aabb1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\ImpersonationController; use App\Http\Controllers\LandingController; +use App\Http\Controllers\StatusController; use App\Http\Controllers\StripeWebhookController; use App\Livewire\Admin; use App\Livewire\Billing; @@ -13,8 +14,56 @@ use App\Livewire\Dashboard; use App\Livewire\Invoices; use App\Livewire\Support; use App\Livewire\Users; +use App\Support\AdminArea; use Illuminate\Support\Facades\Route; +/* +| The operator console, registered BEFORE anything else in this file. +| +| Once the console has a hostname to itself it sits at the ROOT of that host — +| where `/`, `/settings` and `/customers` also exist further down for the +| public site and the customer portal. Laravel takes the first matching route, +| so the order of these two blocks is the separation. +| +| Where it mounts is AdminArea's call: `/` on the console hostname where it has +| one to itself, `/admin` on any host otherwise. The names stay `admin.*` in +| both modes, so nothing that generates a URL has to know which mode it is in. +*/ +if (AdminArea::isExclusive()) { + // Once per accepted hostname, and the canonical one LAST. + // + // ADMIN_HOSTS may list alternates — a bare IP, a second name — and those + // are the recovery paths someone locked out reaches for. Binding only the + // first would make the console 404 through every one of them. Registering + // the canonical host last means route() generates URLs for it, while all of + // them still match. + $hosts = AdminArea::hosts(); + foreach (array_reverse($hosts) as $host) { + Route::domain($host) + ->middleware(['admin.host', 'auth', 'admin']) + ->prefix(AdminArea::prefix()) + ->name('admin.') + ->group(base_path('routes/admin.php')); + } +} else { + // Shared with the portal: no hostname binding, and the /admin prefix keeps + // the two apart by path. + Route::middleware(['admin.host', 'auth', 'admin']) + ->prefix(AdminArea::prefix()) + ->name('admin.') + ->group(base_path('routes/admin.php')); +} + +// Old console addresses, once the console has moved off /admin. Permanent, +// because it really has moved, and only on the console's own host so this +// never hands a stranger a redirect that confirms a console exists. +if (AdminArea::isExclusive()) { + Route::domain(AdminArea::host()) + ->get('/admin/{rest?}', fn (?string $rest = null) => redirect('/'.($rest ?? ''), 301)) + ->where('rest', '.*') + ->name('admin.legacy'); +} + Route::get('/', LandingController::class)->name('home'); // Generated, not a static file: while the site is hidden this has to say so, @@ -30,12 +79,19 @@ Route::get('/robots.txt', function () { // Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed). Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe'); +// The service status page. Its own address: it used to sit under /legal beside +// the imprint and the terms, and nothing about the current health of the +// platform is a legal document. +Route::get('/status', StatusController::class)->name('status'); + // Public legal pages (placeholders — replace with real content before launch). Route::prefix('legal')->name('legal.')->group(function () { Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb'); - Route::get('/status', fn () => view('legal', ['title' => 'Status']))->name('status'); + // Kept so existing links and bookmarks do not 404 — permanently, because + // the page has genuinely moved. + Route::permanentRedirect('/status', '/status')->name('status'); }); // Guest auth pages — full-page class-based Livewire components (R1/R2). Fortify @@ -67,27 +123,3 @@ Route::middleware(['auth', 'customer.active'])->group(function () { // POST so Laravel's CSRF middleware protects the identity change. Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave'); }); - -// Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). -// RestrictAdminHost is BOTH prepended to the `web` group (deterministic 404 on -// direct hits, even for guests) AND listed here so Livewire records it on the -// component snapshot and re-applies it to /livewire/update requests — otherwise -// admin actions could be invoked through a public hostname. -Route::middleware(['admin.host', 'auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { - Route::get('/', Admin\Overview::class)->name('overview'); - Route::get('/customers', Admin\Customers::class)->name('customers'); - Route::get('/instances', Admin\Instances::class)->name('instances'); - Route::get('/hosts', Admin\Hosts::class)->name('hosts'); - Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); - Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); - Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); - Route::get('/plans', Admin\Plans::class)->name('plans'); - Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); - // POST so the identity change is CSRF-protected (a GET could be forced cross-site). - Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); - Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); - Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); - Route::get('/vpn', Admin\Vpn::class)->name('vpn'); - Route::get('/revenue', Admin\Revenue::class)->name('revenue'); - Route::get('/settings', Admin\Settings::class)->name('settings'); -}); diff --git a/tests/Feature/Admin/ConsoleHostSeparationTest.php b/tests/Feature/Admin/ConsoleHostSeparationTest.php new file mode 100644 index 0000000..87246f1 --- /dev/null +++ b/tests/Feature/Admin/ConsoleHostSeparationTest.php @@ -0,0 +1,122 @@ +setRouteResolver(function () use ($routeName, $path) { + $route = new RoutingRoute(['GET'], $path, fn () => null); + + return $routeName !== null ? $route->name($routeName) : $route; + }); + + try { + (new RestrictAdminHost)->handle($request, fn () => response('through')); + + return 'through'; + } catch (NotFoundHttpException) { + return '404'; + } +} + +describe('shared host', function () { + beforeEach(function () { + config()->set('admin_access.hosts', ['10.10.90.185', 'localhost']); + config()->set('admin_access.exclusive', false); + }); + + it('keeps the console under /admin', function () { + expect(AdminArea::prefix())->toBe('admin') + ->and(AdminArea::isConsole(Request::create('http://localhost/admin/customers')))->toBeTrue() + ->and(AdminArea::isConsole(Request::create('http://localhost/cloud')))->toBeFalse(); + }); + + it('still serves the portal and the public site from that host', function () { + // The regression: a machine reached by IP has that IP in ADMIN_HOSTS, + // and 404ing everything else there removes the product from it. + expect(guard('localhost', '/cloud', 'cloud'))->toBe('through') + ->and(guard('localhost', '/', 'home'))->toBe('through') + ->and(guard('localhost', '/status', 'status'))->toBe('through'); + }); + + it('keeps the console off a host that is not listed', function () { + expect(guard('www.example.test', '/admin', 'admin.overview'))->toBe('404'); + }); +}); + +describe('exclusive host', function () { + beforeEach(function () { + config()->set('admin_access.hosts', ['admin.example.test']); + config()->set('admin_access.exclusive', true); + }); + + it('puts the console at the root of its own host', function () { + expect(AdminArea::prefix())->toBe('') + ->and(AdminArea::home())->toBe('/') + ->and(AdminArea::isConsole(Request::create('http://admin.example.test/customers')))->toBeTrue() + ->and(AdminArea::isConsole(Request::create('http://app.example.test/customers')))->toBeFalse(); + }); + + it('answers nothing but the console on that host', function () { + expect(guard('admin.example.test', '/', 'admin.overview'))->toBe('through') + ->and(guard('admin.example.test', '/customers', 'admin.customers'))->toBe('through') + // The portal and the public site are gone from this hostname. + ->and(guard('admin.example.test', '/cloud', 'cloud'))->toBe('404') + ->and(guard('admin.example.test', '/status', 'status'))->toBe('404') + ->and(guard('admin.example.test', '/dashboard', 'dashboard'))->toBe('404'); + }); + + it('lets the shared authentication endpoints through on both hosts', function () { + // Sign-in posts and Livewire's own endpoints are used by both sides. If + // this list is wrong the symptom is "the console is broken", so it is + // written out rather than inferred. + foreach (['/login', '/logout', '/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { + expect(guard('admin.example.test', $path, null))->toBe('through', $path); + } + }); + + it('keeps the console off the public hosts', function () { + expect(guard('app.example.test', '/', 'admin.overview'))->toBe('404') + ->and(guard('www.example.test', '/customers', 'admin.customers'))->toBe('404'); + }); + + it('still serves the public site on its own host', function () { + expect(guard('www.example.test', '/', 'home'))->toBe('through') + ->and(guard('app.example.test', '/dashboard', 'dashboard'))->toBe('through'); + }); +}); + +it('never treats a request as the console just because a hostname is configured', function () { + // isConsole drives the public-site switch and the network allowlist. Getting + // it wrong in this direction hands the console's exemptions to the portal. + config()->set('admin_access.hosts', ['admin.example.test']); + config()->set('admin_access.exclusive', false); + + expect(AdminArea::isHostBound())->toBeTrue() + ->and(AdminArea::isExclusive())->toBeFalse() + ->and(AdminArea::isConsole(Request::create('http://admin.example.test/cloud')))->toBeFalse() + ->and(AdminArea::isConsole(Request::create('http://admin.example.test/admin')))->toBeTrue(); +}); diff --git a/tests/Feature/MonitoringStatusSyncTest.php b/tests/Feature/MonitoringStatusSyncTest.php new file mode 100644 index 0000000..e238d11 --- /dev/null +++ b/tests/Feature/MonitoringStatusSyncTest.php @@ -0,0 +1,107 @@ +create(['status' => 'active']); + + return MonitoringTarget::query()->create(array_merge([ + 'instance_id' => $instance->id, + 'external_id' => $externalId, + 'url' => 'https://example.test', + 'status' => 'unknown', + ], $attributes)); +} + +it('records what monitoring actually reports', function () { + $up = target('mon-up'); + $down = target('mon-down'); + + $this->mock(MonitoringClient::class, function ($mock) { + $mock->shouldReceive('health')->with('mon-up')->andReturnTrue(); + $mock->shouldReceive('health')->with('mon-down')->andReturnFalse(); + }); + + (new SyncMonitoringStatus)->handle(app(MonitoringClient::class)); + + expect($up->refresh()->status)->toBe('up') + ->and($up->checked_at)->not->toBeNull() + ->and($down->refresh()->status)->toBe('down'); +}); + +it('does not blame the instance when the monitoring service is the one that is down', function () { + // "The monitor did not answer" says nothing about the instance. Writing + // 'down' would raise an alarm about the wrong system; writing 'up' would + // hide a real one. + $target = target('mon-x', ['status' => 'up', 'checked_at' => now()->subMinutes(3)]); + $wasCheckedAt = $target->checked_at; + + $this->mock(MonitoringClient::class, function ($mock) { + $mock->shouldReceive('health')->andThrow(new RuntimeException('connection refused')); + }); + + (new SyncMonitoringStatus)->handle(app(MonitoringClient::class)); + + expect($target->refresh()->status)->toBe('up') + // Not advanced — so the verdict is allowed to go stale and stop counting. + ->and($target->checked_at->timestamp)->toBe($wasCheckedAt->timestamp); +}); + +it('does not treat a never-checked target as healthy on the public page', function () { + target('mon-fresh'); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.state.unknown')) + ->assertDontSee(__('status.overall.operational')); +}); + +it('stops trusting a verdict nobody has refreshed', function () { + target('mon-old', ['status' => 'up', 'checked_at' => now()->subHours(2)]); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.state.unknown')); +}); + +it('records nothing when the monitor cannot answer at all', function () { + // The boolean contract folded "no monitoring configured" into true and + // "monitor unreachable" into false. Recording either as a measurement + // publishes a fleet-wide all-clear nobody took, or a fleet-wide outage that + // is really one broken monitor. + $target = target('mon-quiet', ['status' => 'up', 'checked_at' => now()->subMinutes(4)]); + $wasCheckedAt = $target->checked_at; + + $this->mock(MonitoringClient::class, function ($mock) { + $mock->shouldReceive('health')->andReturnNull(); + }); + + (new SyncMonitoringStatus)->handle(app(MonitoringClient::class)); + + expect($target->refresh()->status)->toBe('up') + ->and($target->checked_at->timestamp)->toBe($wasCheckedAt->timestamp); +}); + +it('keeps the acceptance check passing when monitoring is not set up', function () { + // isHealthy() still folds "cannot tell" into a pass — deliberately, and + // only there: a delivery must not fail because monitoring is unconfigured. + config()->set('services.monitoring.url', ''); + + $client = new App\Services\Monitoring\HttpMonitoringClient; + + expect($client->health('anything'))->toBeNull() + ->and($client->isHealthy('anything'))->toBeTrue(); +}); diff --git a/tests/Feature/WelcomeTest.php b/tests/Feature/WelcomeTest.php index 24e154e..49f5528 100644 --- a/tests/Feature/WelcomeTest.php +++ b/tests/Feature/WelcomeTest.php @@ -9,4 +9,43 @@ it('renders the public landing page with a sign-in link', function () { it('renders the legal placeholder pages', function (string $route) { $this->get(route($route))->assertOk()->assertSee('CluPilot'); -})->with(['legal.impressum', 'legal.datenschutz', 'legal.agb', 'legal.status']); +})->with(['legal.impressum', 'legal.datenschutz', 'legal.agb']); + +it('serves the status page at its own address, not under legal', function () { + // It used to sit beside the imprint and the terms. Nothing about the + // current health of the platform is a legal document. + $this->get('/status') + ->assertOk() + ->assertSee(__('status.component.instances')) + ->assertSee(__('status.component.backups')); + + // The old address still works, permanently, for anything already linking it. + $this->get('/legal/status')->assertRedirect('/status'); +}); + +it('reports a component it cannot see as unknown, never as operational', function () { + // A status page that reports green because nothing is being watched is + // worse than no status page: someone will believe it. + App\Models\Instance::factory()->create(['status' => 'active']); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.state.unknown')) + ->assertDontSee(__('status.overall.operational')); +}); + +it('does not call the estate protected when an instance has no backup at all', function () { + // Counting backup ROWS leaves an instance with no schedule out of the + // arithmetic entirely, and the page reports green because the backups that + // do exist happen to be fine. + $withBackup = App\Models\Instance::factory()->create(['status' => 'active']); + App\Models\Backup::query()->create([ + 'instance_id' => $withBackup->id, 'external_job_id' => 'job-1', + 'schedule' => 'daily', 'status' => 'ok', 'last_ok_at' => now()->subHour(), + ]); + App\Models\Instance::factory()->create(['status' => 'active']); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.detail.backups', ['stale' => 1, 'total' => 2])); +});