240 lines
12 KiB
PHP
240 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Actions\RestartInstance;
|
|
use App\Models\Instance;
|
|
use App\Services\Billing\StorageAllowance;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Lang;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
/**
|
|
* Every instance on the estate, from the instances table.
|
|
*
|
|
* This page used to list seven invented instances on four invented hosts,
|
|
* complete with a Nextcloud version column. The version is not recorded
|
|
* anywhere, so the column is gone rather than filled in with something
|
|
* plausible — and the storage column now shows the quota that was actually
|
|
* sold, not a made-up "used of total", because used disk is not collected.
|
|
*/
|
|
#[Layout('layouts.admin')]
|
|
class Instances extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
/**
|
|
* Wie lange ein Archiv im Abschnitt „Archiviert und abgebaut" steht.
|
|
*
|
|
* Zwölf Monate, weil das die Aufbewahrungsfrist ist, an deren Ende der Dump
|
|
* selbst gelöscht wird (siehe die Wanderung von `archived_at` in der
|
|
* Migration). Damit hat dieser Abschnitt ein Ende, das ihm jemand anderes
|
|
* setzt: Was hier steht, ist genau das, was noch auf einer Ablage liegt und
|
|
* sich zurückholen lässt. Ein Abschnitt, der jede Löschung seit Anbeginn
|
|
* aufführt, ist nach dem zweiten Jahr eine Liste, die niemand mehr liest —
|
|
* derselbe Fehler wie beim Kasten „Datenexport bestellt" darunter, der sich
|
|
* bis heute nicht abräumen lässt.
|
|
*
|
|
* Der Lauf, der die Frist wirklich durchsetzt, ist noch nicht gebaut (er
|
|
* gehört zu dem Zeitpunkt gebaut, an dem das erste Archiv alt genug wird).
|
|
* Bis dahin blendet dieser Abschnitt aus, was ohnehin nicht mehr da sein
|
|
* soll — und die Angabe steht weiter am Datensatz, für den Tag, an dem der
|
|
* Aufräumlauf sie braucht.
|
|
*/
|
|
private const ARCHIVE_MONTHS = 12;
|
|
|
|
/**
|
|
* Restart one instance, once ConfirmRestartInstance has said so.
|
|
*
|
|
* The capability is checked twice on purpose, and neither check is the
|
|
* decoration of the other. This one keeps an operator without
|
|
* `instances.restart` from driving the console component at all; the one
|
|
* inside RestartInstance is what actually guards the machine, because that
|
|
* is the door a hand-written POST to /livewire/update arrives at. Losing
|
|
* either would still leave a customer's cloud reachable by somebody who was
|
|
* only ever meant to read the list.
|
|
*/
|
|
#[On('instance-restart-confirmed')]
|
|
public function restart(string $uuid): void
|
|
{
|
|
$this->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(),
|
|
]);
|
|
}
|
|
}
|