Move the console off /admin, give the status page its own address, and measure monitoring
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 <noreply@anthropic.com>feat/portal-design tested-20260727-0429-de6821b
parent
276f926d57
commit
de6821b53e
|
|
@ -0,0 +1,194 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Instance;
|
||||||
|
use App\Models\MonitoringTarget;
|
||||||
|
use App\Models\ProvisioningRun;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public service status page.
|
||||||
|
*
|
||||||
|
* It lived at /legal/status, next to the imprint and the terms, which is a
|
||||||
|
* filing mistake: nothing about the current health of the platform is a legal
|
||||||
|
* document. It has its own address now.
|
||||||
|
*
|
||||||
|
* Every line on it is derived from a record. Where there is no signal the
|
||||||
|
* component says "not monitored" rather than "operational" — a status page that
|
||||||
|
* reports green because it has nothing to look at is worse than no status page,
|
||||||
|
* because someone will believe it.
|
||||||
|
*
|
||||||
|
* Aggregate only: counts, never customer names, instance addresses or host
|
||||||
|
* names. This page is world-readable and the estate is not public information.
|
||||||
|
*/
|
||||||
|
class StatusController extends Controller
|
||||||
|
{
|
||||||
|
/** A backup older than this stops counting as current. */
|
||||||
|
private const BACKUP_STALE_AFTER_HOURS = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How old a monitoring verdict may be before it stops counting.
|
||||||
|
*
|
||||||
|
* The sync job runs every five minutes; four missed runs is a monitoring
|
||||||
|
* pipeline that has stopped, and a verdict from then says nothing about now.
|
||||||
|
*/
|
||||||
|
private const MONITORING_STALE_AFTER_MINUTES = 20;
|
||||||
|
|
||||||
|
/** How far back a provisioning failure still says something about now. */
|
||||||
|
private const PROVISIONING_WINDOW_HOURS = 24;
|
||||||
|
|
||||||
|
public function __invoke(): View
|
||||||
|
{
|
||||||
|
$components = [
|
||||||
|
$this->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<string, mixed>
|
||||||
|
*/
|
||||||
|
private function portal(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => 'portal',
|
||||||
|
'state' => 'operational',
|
||||||
|
'detail' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer instances, from what monitoring last saw.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,8 +64,11 @@ class RestrictAdminHost
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The portal or the public site, reached through the console's hostname.
|
// The portal or the public site, reached through the console's
|
||||||
if ($onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) {
|
// 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);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,8 +277,13 @@ class Overview extends Component
|
||||||
|
|
||||||
// Distinct instances, not targets: one instance can be watched by
|
// Distinct instances, not targets: one instance can be watched by
|
||||||
// several checks, and counting checks would report three outages where
|
// several checks, and counting checks would report three outages where
|
||||||
// one machine is down.
|
// one machine is down. And only verdicts the sync job has refreshed —
|
||||||
$down = MonitoringTarget::query()->where('status', '!=', 'up')->distinct()->count('instance_id');
|
// 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) {
|
if ($down > 0) {
|
||||||
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
|
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class MonitoringTarget extends Model
|
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
|
public function instance(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Provisioning\Jobs;
|
||||||
|
|
||||||
|
use App\Models\MonitoringTarget;
|
||||||
|
use App\Services\Monitoring\MonitoringClient;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the monitoring service how each target is actually doing.
|
||||||
|
*
|
||||||
|
* Without this the status column was a fossil: written as 'up' when the
|
||||||
|
* instance was provisioned and never touched again. The console's notices and
|
||||||
|
* the public status page both read it, so an outage would have been reported as
|
||||||
|
* healthy indefinitely — on a page whose entire purpose is to be believed.
|
||||||
|
*
|
||||||
|
* A target the service cannot answer for becomes 'unknown', never 'up'. The
|
||||||
|
* absence of an answer is not a healthy answer, and checked_at is only advanced
|
||||||
|
* when the service really replied — so a page can tell a fresh verdict from a
|
||||||
|
* stale one.
|
||||||
|
*/
|
||||||
|
class SyncMonitoringStatus implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function handle(MonitoringClient $monitoring): void
|
||||||
|
{
|
||||||
|
MonitoringTarget::query()
|
||||||
|
->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,7 +67,9 @@ class RegisterMonitoring extends CustomerStep
|
||||||
// Local row BEFORE the breadcrumb (the short-circuit guard).
|
// Local row BEFORE the breadcrumb (the short-circuit guard).
|
||||||
$instance->monitoringTargets()->firstOrCreate(
|
$instance->monitoringTargets()->firstOrCreate(
|
||||||
['external_id' => $targetId],
|
['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);
|
$this->recordResource($run, $instance->host, 'monitoring_target_id', $targetId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,11 @@ class FakeMonitoringClient implements MonitoringClient
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isHealthy(string $externalId): bool
|
public function isHealthy(string $externalId): bool
|
||||||
|
{
|
||||||
|
return $this->health($externalId) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function health(string $externalId): ?bool
|
||||||
{
|
{
|
||||||
return $this->healthy;
|
return $this->healthy;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,20 +35,29 @@ class HttpMonitoringClient implements MonitoringClient
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isHealthy(string $externalId): bool
|
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');
|
$endpoint = (string) config('services.monitoring.url');
|
||||||
|
|
||||||
if (blank($endpoint)) {
|
if (blank($endpoint)) {
|
||||||
return true; // no monitoring service configured
|
return null; // nothing is watching, which is not the same as healthy
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$monitor = Http::withToken((string) config('services.monitoring.token'))
|
$monitor = Http::withToken((string) config('services.monitoring.token'))
|
||||||
->get(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw()->json('monitor', []);
|
->get(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw()->json('monitor', []);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null; // the monitor is unreachable, which says nothing about the instance
|
||||||
|
}
|
||||||
|
|
||||||
return ($monitor['status'] ?? '') === 'up';
|
return ($monitor['status'] ?? '') === 'up';
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deregisterTarget(string $externalId): void
|
public function deregisterTarget(string $externalId): void
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,23 @@ interface MonitoringClient
|
||||||
|
|
||||||
public function deregisterTarget(string $externalId): void;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** Whether the console is bound to a hostname at all. */
|
||||||
public static function isHostBound(): bool
|
public static function isHostBound(): bool
|
||||||
{
|
{
|
||||||
|
|
@ -75,7 +93,10 @@ final class AdminArea
|
||||||
*/
|
*/
|
||||||
public static function isConsole(Request $request): bool
|
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())
|
? self::covers($request->getHost())
|
||||||
: $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*');
|
: $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*');
|
||||||
}
|
}
|
||||||
|
|
@ -89,12 +110,12 @@ final class AdminArea
|
||||||
/**
|
/**
|
||||||
* The path prefix console routes are registered under.
|
* The path prefix console routes are registered under.
|
||||||
*
|
*
|
||||||
* Empty when host-bound (the console is at the root of its own hostname),
|
* Empty when the console has its hostname to itself — it is then the root
|
||||||
* `admin` otherwise.
|
* of that host. `admin` otherwise.
|
||||||
*/
|
*/
|
||||||
public static function prefix(): string
|
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. */
|
/** Where the console's own front page lives, as a path. */
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,16 @@ return [
|
||||||
explode(',', (string) env('ADMIN_HOSTS', ''))
|
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
|
| Key for VPN configs stored at rest (32 bytes, base64). Separate from
|
||||||
| APP_KEY on purpose — see App\Services\Wireguard\ConfigVault. Empty means
|
| APP_KEY on purpose — see App\Services\Wireguard\ConfigVault. Empty means
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When monitoring last actually answered for a target.
|
||||||
|
*
|
||||||
|
* The status column was written once, at provisioning, with the value 'up', and
|
||||||
|
* nothing ever updated it. Every reader — the console's notices, the public
|
||||||
|
* status page — therefore reported every instance as healthy forever, including
|
||||||
|
* through an outage. A stored verdict with no timestamp cannot be told apart
|
||||||
|
* from a fresh one, so the timestamp comes first and the sync job comes with it.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('monitoring_targets', function (Blueprint $table) {
|
||||||
|
$table->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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -96,4 +96,11 @@ return [
|
||||||
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
|
'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_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',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overall' => [
|
||||||
|
'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.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -96,4 +96,11 @@ return [
|
||||||
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
|
'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_failed' => 'The update failed (code :code). See the log.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'update_last_run' => 'Last run: :state, :when.',
|
||||||
|
'update_state' => [
|
||||||
|
'succeeded' => 'succeeded',
|
||||||
|
'failed' => 'failed',
|
||||||
|
'running' => 'running',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overall' => [
|
||||||
|
'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.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -863,7 +863,7 @@
|
||||||
<a href="{{ route('legal.impressum') }}">Impressum</a>
|
<a href="{{ route('legal.impressum') }}">Impressum</a>
|
||||||
<a href="{{ route('legal.datenschutz') }}">Datenschutz</a>
|
<a href="{{ route('legal.datenschutz') }}">Datenschutz</a>
|
||||||
<a href="{{ route('legal.agb') }}">AGB</a>
|
<a href="{{ route('legal.agb') }}">AGB</a>
|
||||||
<a href="{{ route('legal.status') }}">Status</a>
|
<a href="{{ route('status') }}">Status</a>
|
||||||
<a href="{{ route('login') }}">Anmelden</a>
|
<a href="{{ route('login') }}">Anmelden</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@
|
||||||
|
|
||||||
{{-- Version & update --}}
|
{{-- Version & update --}}
|
||||||
@if ($canManageSite)
|
@if ($canManageSite)
|
||||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
|
{{-- 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. --}}
|
||||||
|
<div class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise"
|
||||||
|
wire:poll.{{ $update['running'] ? '3s' : '30s' }}>
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -50,13 +54,25 @@
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Bound, not @disabled(): the directive compiles to inline PHP
|
{{-- Offered only when there is something to install. Bound, not
|
||||||
inside the component tag, and Blade then no longer
|
@disabled(): the directive compiles to inline PHP inside the
|
||||||
recognises the tag as a component at all. --}}
|
component tag, and Blade then stops seeing a component. --}}
|
||||||
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled"
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
:disabled="$update['running'] || ! $update['agent_seen']">
|
@if ($update['running'])
|
||||||
|
<x-ui.button variant="secondary" :disabled="true">
|
||||||
|
<x-ui.icon name="refresh" class="size-4 animate-spin" />
|
||||||
|
{{ __('admin_settings.update_running') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@elseif ($update['available'])
|
||||||
|
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled">
|
||||||
{{ __('admin_settings.update_now') }}
|
{{ __('admin_settings.update_now') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
|
@else
|
||||||
|
<x-ui.button variant="secondary" :disabled="true">
|
||||||
|
{{ $update['behind'] === 0 ? __('admin_settings.update_current') : __('admin_settings.update_unknown') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (! $update['agent_seen'])
|
@if (! $update['agent_seen'])
|
||||||
|
|
@ -65,8 +81,19 @@
|
||||||
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
|
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($update['last_state'] !== null && ! $update['running'])
|
||||||
|
<p class="mt-3 text-xs text-muted">
|
||||||
|
{{ __('admin_settings.update_last_run', [
|
||||||
|
'state' => __('admin_settings.update_state.'.$update['last_state']),
|
||||||
|
'when' => $update['last_finished_at']?->diffForHumans() ?? '—',
|
||||||
|
]) }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Open while it runs: "läuft gerade" on its own tells an operator
|
||||||
|
nothing about whether it is progressing or wedged. --}}
|
||||||
@if ($updateLog)
|
@if ($updateLog)
|
||||||
<details class="mt-4">
|
<details class="mt-4" @if ($update['running']) open @endif>
|
||||||
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
|
<summary class="cursor-pointer text-sm font-medium text-accent-text">{{ __('admin_settings.update_log') }}</summary>
|
||||||
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
|
<pre class="mt-2 max-h-64 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs leading-relaxed text-body">{{ $updateLog }}</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Status — CluPilot Cloud</title>
|
||||||
|
<meta name="description" content="Aktueller Betriebszustand der CluPilot Cloud: Portal, Kundeninstanzen, Bereitstellung und Sicherungen.">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
|
||||||
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/ibm-plex-serif-latin-600-normal.woff2" crossorigin>
|
||||||
|
@verbatim
|
||||||
|
<style>
|
||||||
|
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-style:normal;font-display:swap}
|
||||||
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
||||||
|
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2");font-weight:500;font-style:normal;font-display:swap}
|
||||||
|
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-style:normal;font-display:swap}
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--paper:#f7f5f1; --paper-2:#efebe3; --card:#fffefb;
|
||||||
|
--ink:#17140f; --ink-soft:#413a31; --muted:#7b7367; --faint:#a39a8c;
|
||||||
|
--rule:#e3ded3; --rule-mid:#cbc3b4;
|
||||||
|
--accent:#f97316; --accent-text:#b8500a;
|
||||||
|
--ok:#2f7d4f; --ok-bg:#eef7f1;
|
||||||
|
--warn:#a2600c; --warn-bg:#fdf5e7;
|
||||||
|
--bad:#a4372a; --bad-bg:#fdf1ef;
|
||||||
|
--unknown:#6c6459; --unknown-bg:#f1eee8;
|
||||||
|
--serif:"Plex Serif",Georgia,serif;
|
||||||
|
--sans:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
|
||||||
|
--mono:"Plex Mono",ui-monospace,monospace;
|
||||||
|
--r:3px;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:var(--sans);background:var(--paper);color:var(--ink-soft);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
:focus-visible{outline:2px solid var(--accent-text);outline-offset:3px}
|
||||||
|
|
||||||
|
.wrap{max-width:760px;margin:0 auto;padding:0 clamp(20px,5vw,32px)}
|
||||||
|
header{padding:clamp(48px,8vw,80px) 0 0}
|
||||||
|
.mark{font-family:var(--serif);font-weight:600;font-size:1.12rem;letter-spacing:-.02em;color:var(--ink)}
|
||||||
|
.mark i{font-style:normal;color:var(--accent-text)}
|
||||||
|
h1{font-family:var(--serif);font-weight:600;font-size:clamp(2rem,4.5vw,2.8rem);letter-spacing:-.028em;line-height:1.1;color:var(--ink);margin-top:28px}
|
||||||
|
|
||||||
|
.overall{
|
||||||
|
display:flex;align-items:center;gap:13px;margin-top:22px;padding:16px 20px;
|
||||||
|
border:1px solid var(--rule-mid);border-radius:var(--r);background:var(--card);
|
||||||
|
}
|
||||||
|
.dot{width:10px;height:10px;border-radius:50%;flex:none}
|
||||||
|
.overall b{font-weight:500;color:var(--ink)}
|
||||||
|
.s-operational .dot{background:var(--ok)} .s-operational b{color:var(--ok)}
|
||||||
|
.s-degraded .dot{background:var(--warn)} .s-degraded b{color:var(--warn)}
|
||||||
|
.s-down .dot{background:var(--bad)} .s-down b{color:var(--bad)}
|
||||||
|
.s-unknown .dot{background:var(--unknown)} .s-unknown b{color:var(--unknown)}
|
||||||
|
|
||||||
|
.list{margin-top:34px;border:1px solid var(--rule-mid);border-radius:var(--r);background:var(--card);overflow:hidden}
|
||||||
|
.row{display:flex;align-items:flex-start;gap:16px;padding:18px 20px;border-bottom:1px solid var(--rule)}
|
||||||
|
.row:last-child{border-bottom:0}
|
||||||
|
.row .name{flex:1;min-width:0}
|
||||||
|
.row .name b{display:block;font-weight:500;color:var(--ink);font-size:.98rem}
|
||||||
|
.row .name small{display:block;color:var(--muted);font-size:.85rem;margin-top:2px;line-height:1.5}
|
||||||
|
.badge{
|
||||||
|
display:inline-flex;align-items:center;gap:8px;flex:none;
|
||||||
|
font-family:var(--mono);font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;
|
||||||
|
padding:5px 11px;border-radius:99px;border:1px solid transparent;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.badge.s-operational{background:var(--ok-bg);color:var(--ok);border-color:#c9e4d4}
|
||||||
|
.badge.s-degraded{background:var(--warn-bg);color:var(--warn);border-color:#f0dcb4}
|
||||||
|
.badge.s-down{background:var(--bad-bg);color:var(--bad);border-color:#f2cdc7}
|
||||||
|
.badge.s-unknown{background:var(--unknown-bg);color:var(--unknown);border-color:var(--rule-mid)}
|
||||||
|
|
||||||
|
.meta{margin-top:20px;font-family:var(--mono);font-size:.74rem;color:var(--muted)}
|
||||||
|
.note{margin-top:26px;padding-top:22px;border-top:1px solid var(--rule);font-size:.88rem;color:var(--muted);line-height:1.65}
|
||||||
|
.note a{color:var(--accent-text);font-weight:500}
|
||||||
|
footer{margin-top:clamp(48px,7vw,72px);padding:24px 0 40px;border-top:1px solid var(--rule)}
|
||||||
|
.fo{display:flex;flex-wrap:wrap;gap:12px 22px;font-family:var(--mono);font-size:.73rem;color:var(--muted)}
|
||||||
|
.fo .right{margin-left:auto;display:flex;flex-wrap:wrap;gap:18px}
|
||||||
|
.fo a:hover{color:var(--ink)}
|
||||||
|
@media(max-width:520px){.row{flex-wrap:wrap}.badge{order:-1}}
|
||||||
|
</style>
|
||||||
|
@endverbatim
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<a class="mark" href="{{ route('home') }}">Clu<i>Pilot</i> Cloud</a>
|
||||||
|
<h1>Betriebsstatus</h1>
|
||||||
|
|
||||||
|
<div class="overall s-{{ $overall }}">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
<b>{{ __('status.overall.'.$overall) }}</b>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
@foreach ($components as $c)
|
||||||
|
<div class="row">
|
||||||
|
<div class="name">
|
||||||
|
<b>{{ __('status.component.'.$c['key']) }}</b>
|
||||||
|
<small>
|
||||||
|
@if ($c['detail'] !== null)
|
||||||
|
{{ __('status.detail.'.$c['key'], $c['detail']) }}
|
||||||
|
@else
|
||||||
|
{{ __('status.about.'.$c['key']) }}
|
||||||
|
@endif
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge s-{{ $c['state'] }}">{{ __('status.state.'.$c['state']) }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="meta">Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr</p>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
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
|
||||||
|
<a href="mailto:office@clupilot.com">office@clupilot.com</a> — Antwort am selben Werktag.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="fo">
|
||||||
|
<span>© {{ date('Y') }} CluPilot Cloud · Österreich</span>
|
||||||
|
<div class="right">
|
||||||
|
<a href="{{ route('home') }}">Startseite</a>
|
||||||
|
<a href="{{ route('legal.impressum') }}">Impressum</a>
|
||||||
|
<a href="{{ route('legal.datenschutz') }}">Datenschutz</a>
|
||||||
|
<a href="{{ route('legal.agb') }}">AGB</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\ImpersonationController;
|
||||||
|
use App\Livewire\Admin;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
| The operator console.
|
||||||
|
|
|
||||||
|
| Its own file, and registered BEFORE routes/web.php, because once the console
|
||||||
|
| has a hostname to itself it lives at the ROOT of that host — where `/` and
|
||||||
|
| `/settings` also exist in the customer portal, and the first matching route
|
||||||
|
| wins.
|
||||||
|
|
|
||||||
|
| Where it is mounted is AdminArea's decision, not this file's: `/` on the
|
||||||
|
| console hostname where the console has one to itself, `/admin` on any host
|
||||||
|
| otherwise. Route NAMES are `admin.*` in both cases, so every route('admin.…')
|
||||||
|
| in the views is unaffected by which mode an installation is in.
|
||||||
|
|
|
||||||
|
| RestrictAdminHost is listed here as well as prepended to the `web` group:
|
||||||
|
| Livewire records it on the component snapshot and re-applies it to
|
||||||
|
| /livewire/update, so a console action cannot be driven through another host.
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
|
@ -24,6 +24,12 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers)
|
||||||
|
|
||||||
// Sample network counters and enforce the monthly allowance. Runs on the
|
// Sample network counters and enforce the monthly allowance. Runs on the
|
||||||
// provisioning queue, which is where the Proxmox credentials are usable.
|
// 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)
|
Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic)
|
||||||
->everyFifteenMinutes()
|
->everyFifteenMinutes()
|
||||||
->name('traffic-collect');
|
->name('traffic-collect');
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Http\Controllers\ImpersonationController;
|
use App\Http\Controllers\ImpersonationController;
|
||||||
use App\Http\Controllers\LandingController;
|
use App\Http\Controllers\LandingController;
|
||||||
|
use App\Http\Controllers\StatusController;
|
||||||
use App\Http\Controllers\StripeWebhookController;
|
use App\Http\Controllers\StripeWebhookController;
|
||||||
use App\Livewire\Admin;
|
use App\Livewire\Admin;
|
||||||
use App\Livewire\Billing;
|
use App\Livewire\Billing;
|
||||||
|
|
@ -13,8 +14,56 @@ use App\Livewire\Dashboard;
|
||||||
use App\Livewire\Invoices;
|
use App\Livewire\Invoices;
|
||||||
use App\Livewire\Support;
|
use App\Livewire\Support;
|
||||||
use App\Livewire\Users;
|
use App\Livewire\Users;
|
||||||
|
use App\Support\AdminArea;
|
||||||
use Illuminate\Support\Facades\Route;
|
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');
|
Route::get('/', LandingController::class)->name('home');
|
||||||
|
|
||||||
// Generated, not a static file: while the site is hidden this has to say so,
|
// 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).
|
// Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed).
|
||||||
Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe');
|
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).
|
// Public legal pages (placeholders — replace with real content before launch).
|
||||||
Route::prefix('legal')->name('legal.')->group(function () {
|
Route::prefix('legal')->name('legal.')->group(function () {
|
||||||
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
||||||
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
|
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
|
||||||
Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb');
|
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
|
// 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.
|
// POST so Laravel's CSRF middleware protects the identity change.
|
||||||
Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave');
|
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');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\RestrictAdminHost;
|
||||||
|
use App\Support\AdminArea;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Route as RoutingRoute;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which host serves the console, and what else that host will answer.
|
||||||
|
*
|
||||||
|
* Two modes, and the difference between them is the whole point:
|
||||||
|
*
|
||||||
|
* - Shared. The console lives under /admin and the same host also serves the
|
||||||
|
* portal and the public site. This is a development machine, which lists its
|
||||||
|
* own IP in ADMIN_HOSTS so the console is reachable without DNS.
|
||||||
|
* - Exclusive. The console has a hostname to itself, sits at the root of it,
|
||||||
|
* and nothing else answers there.
|
||||||
|
*
|
||||||
|
* Shipping the exclusive rule without the switch took the public site off a
|
||||||
|
* development machine, because that machine's own IP was in ADMIN_HOSTS. The
|
||||||
|
* modes are tested apart for that reason.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Drive the guard directly: route registration is fixed at boot. */
|
||||||
|
function guard(string $host, string $path, ?string $routeName): Response|string
|
||||||
|
{
|
||||||
|
$request = Request::create("http://{$host}{$path}", 'GET');
|
||||||
|
|
||||||
|
$request->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();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Instance;
|
||||||
|
use App\Models\MonitoringTarget;
|
||||||
|
use App\Provisioning\Jobs\SyncMonitoringStatus;
|
||||||
|
use App\Services\Monitoring\MonitoringClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitoring verdicts have to be measured, not remembered.
|
||||||
|
*
|
||||||
|
* The status column was written once — 'up', at provisioning — and nothing ever
|
||||||
|
* updated it. The console's notices and the public status page both read it, so
|
||||||
|
* an outage would have been reported as healthy indefinitely, on a page whose
|
||||||
|
* whole purpose is to be believed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function target(string $externalId, array $attributes = []): MonitoringTarget
|
||||||
|
{
|
||||||
|
$instance = Instance::factory()->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();
|
||||||
|
});
|
||||||
|
|
@ -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) {
|
it('renders the legal placeholder pages', function (string $route) {
|
||||||
$this->get(route($route))->assertOk()->assertSee('CluPilot');
|
$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]));
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue