authorize('instances.restart'); $instance = Instance::query()->where('uuid', $uuid)->first(); if ($instance === null) { return; } try { $run = app(RestartInstance::class)($instance); } catch (AuthorizationException) { $this->dispatch('notify', message: __('admin.restart_denied')); return; } // Null is every ordinary refusal — no live machine, or a run already in // flight against this order. Said out loud, because an operator who // pressed a button and saw nothing will press it again. $this->dispatch('notify', message: __($run === null ? 'admin.restart_busy' : 'admin.restart_started')); } public function render() { $instances = Instance::query() ->with(['customer', 'host']) ->orderByDesc('id') ->paginate(25); return view('livewire.admin.instances', [ 'instances' => $instances, // Der wichtigste Kasten der Seite: Abbauten, die hängengeblieben // sind. // // `teardown_error` ist der Grund, den ArchiveAndTearDown an den // Datensatz schreibt, statt zu werfen. Ohne Leser wäre er dasselbe // gewesen wie `export_wish` vor Teil A — ein Zustand in einer // Spalte, den niemand zu Gesicht bekommt. Nur hätte es hier mehr // gekostet als eine unbeantwortete Frage: eine Instanz mit gefülltem // Grund steht UNBEGRENZT und belegt weiter einen Platz auf ihrem // Host. Sieht sie niemand, war der ganze Abbau umsonst. // // Ganz oben und in Rot, nicht als Plakette in der Zeile: die Tabelle // darunter ist nach id absteigend seitenweise geblättert, eine vor // Monaten gescheiterte Instanz sässe also auf Seite acht. Ein // Fehler, den man finden könnte, ist kein Fehler, der auffällt. // // Er räumt sich selbst ab, und das ist der Unterschied zum Kasten // darunter: der nächtliche Lauf versucht es erneut, und ein // erfolgreicher Abbau setzt `teardown_error` auf null und // `torn_down_at`. Die Zeile verschwindet dann, ohne dass jemand // Buch führt. // // Nach dem letzten Versuch absteigend: was heute Nacht gescheitert // ist, steht oben. Aufsteigend zu sortieren — wie es der Kasten // „Datenexport bestellt" tut — hiesse, dass der älteste Dauerfall // den Kopf der Liste für immer besetzt und die frischen Fehler nach // unten wandern. 'stuck' => Instance::query() ->with(['customer', 'host']) ->whereNotNull('teardown_error') ->whereNull('torn_down_at') ->orderByDesc('updated_at') ->get() ->map(fn (Instance $i) => [ 'customer' => $i->customer?->name ?? '—', 'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain, 'machine' => ($i->host?->name ?? '—').' · '.($i->vmid ?? '—'), 'reason' => $i->teardown_error, // `updated_at` ist der beste Zeitpunkt, den es gibt: der // Fehlschlag hat keine eigene Spalte, und an einer Instanz // in diesem Zustand schreibt nichts anderes mehr. // R19: die Wanduhr des Betreibers, nicht die Speicherzone. 'attempted' => $i->updated_at?->local()->isoFormat('LLL') ?? '—', // Der Zwischenstand, den es wirklich gibt: gesichert, aber // nicht gelöscht. Wer ihn von Hand zu Ende bringt, braucht // genau diese Kennung — sonst sichert er ein zweites Mal. 'archive' => $i->archive_volid, ]) ->all(), // Was abgebaut wurde, und wo das Archiv liegt. // // Ohne diese Angabe ist eine Wiederherstellung eine Suche über // fremde Ablagen. `archive_volid` steht deshalb im Klartext da und // nicht hinter einem Hinweistext. // // Absteigend nach dem Zeitpunkt der Archivierung, und begrenzt auf // die Aufbewahrungsfrist — siehe ARCHIVE_MONTHS. 'teardowns' => Instance::query() ->with('customer') ->whereNotNull('torn_down_at') ->whereNotNull('archive_volid') ->whereNotNull('archived_at') ->where('archived_at', '>=', now()->subMonths(self::ARCHIVE_MONTHS)) ->orderByDesc('archived_at') ->get() ->map(fn (Instance $i) => [ 'customer' => $i->customer?->name ?? '—', 'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain, 'archive' => $i->archive_volid, // R19: die Wanduhr des Betreibers, nicht die Speicherzone. 'archived_at' => $i->archived_at?->local()->isoFormat('LLL') ?? '—', ]) ->all(), // Die bestellten Datenexporte, ueber der Liste. // // `export_wish` stand bis hierher in einer Spalte, die kein Mensch // zu Gesicht bekam: ein „Ja" aus dem Kuendigungsdialog hatte in // der ganzen Konsole keinen einzigen Leser, und selbst ein Ausweg // von Hand haette eine Datenbankabfrage gebraucht. Eine Frage // ohne Empfaenger ist keine Frage. // // Bewusst ein eigener Abschnitt und keine Plakette in der Zeile: // die Tabelle darunter ist nach id absteigend seitenweise // geblaettert, ein vor Monaten bestellter Export saesse also auf // Seite acht. Und bewusst hier statt auf der Uebersicht — die // Liste ist die Seite, auf die ein Betreiber nach einer // Kuendigung ohnehin schaut, und ein Hinweis auf der Startseite, // den nichts je wieder abraeumen kann, waere Moebel. // // Nach Laufzeitende sortiert, nicht nach id: der naechste Termin // steht oben. `ended` bleibt drin — dann ist der Export faellig, // nicht erledigt. 'exports' => Instance::query() ->with('customer') ->where('export_wish', true) ->whereIn('status', ['cancellation_scheduled', 'ended']) ->orderBy('service_ends_at') ->get() ->map(fn (Instance $i) => [ 'customer' => $i->customer?->name ?? '—', 'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain, // R19: die Wanduhr des Betreibers, nicht die Speicherzone. 'ends_at' => $i->service_ends_at?->local()->isoFormat('LL') ?? '—', 'over' => $i->service_ends_at !== null && $i->service_ends_at->isPast(), ]) ->all(), // Whether to draw the action column at all. An operator who may not // restart anything should not be reading a column of buttons that // answer them 403. 'canRestart' => Gate::allows('instances.restart'), 'rows' => $instances->getCollection()->map(fn (Instance $i) => [ // R11: a row's action is addressed by uuid, never the numeric id. 'uuid' => $i->uuid, // Only a machine remote work can actually reach. A reservation // with no VM, a failed build and an ended service all have // nothing to restart — see Instance::hasLiveMachine(). 'live' => $i->hasLiveMachine(), // The operator list shows what is actually served — an unverified // custom domain is a plan, not an address. 'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain, 'customer' => $i->customer?->name ?? '—', 'host' => $i->host?->name ?? '—', 'vmid' => $i->vmid ?? '—', 'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—', // The whole allowance, packs included — the same figure the // customer's own page states and the same one Nextcloud is // told. An operator reading the package alone here would be // looking at a smaller number than the machine enforces and // would have no way of telling. 'quota' => ($owed = StorageAllowance::for($i)->totalGb()) > 0 ? $owed.' GB' : '—', // A machine running on less CPU or RAM than it has been sold — // see Instance::restartIsPending(). Shown beside the status // rather than as one, because the instance is genuinely active // and the operator needs both facts at once. 'restart' => $i->restartIsPending(), 'status' => $status = $i->status ?? 'provisioning', // A status the lifecycle adds later must show as itself, never // as "admin.status.whatever" in front of the owner. 'status_label' => Lang::has('admin.status.'.$status) ? __('admin.status.'.$status) : $status, ])->all(), ]); } }