Release v1.3.91 — die Host-Seite zeigt Last statt Ausstattung
tests / pest (push) Failing after 9m29s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

Die Karte "Rechenleistung" zeigte keine Leistung. 12 Kerne und 63 GB sind die
Ausstattung des Blechs und ändern sich nie — sie standen aber im selben
Kartenraster wie "Zustand" und "Speicher", die beide leben. Wer die Seite
öffnete, um zu sehen, wie es dem Host geht, las dort eine Zahl, die das nie
sagen konnte.

An ihrer Stelle steht jetzt die Last: CPU und RAM als Stundenkurve, beide in
Prozent auf EINER Achse, dazu die aktuellen Werte als beschriftete Zahlen.

Die Geschichte kommt aus Proxmox' eigener Aufzeichnung
(/nodes/{node}/rrddata), nicht aus einem eigenen Sampler. Ein Sampler hieße
neue Tabelle, minütlicher Job, Aufräum-Job und ~1440 Zeilen je Host und Tag —
um weniger genau nachzubauen, was ohnehin auf der Platte liegt. Die RRD ist ab
der ersten Sekunde gefüllt, auch für die Stunde vor dem ersten Hinsehen, und
kann nicht von dem abweichen, was Proxmox' eigene Oberfläche zeigt.

Eine Lücke bleibt eine Lücke: ein Punkt ohne Messwert wird null, nie 0, und
spanGaps steht auf false. Dieselbe Regel wie in instance_metrics. Antwortet der
Host gar nicht, sagt die Tafel das in einem Satz, statt eine ruhige Stunde zu
zeichnen — auf dem Testhost live bestätigt.

Der Fehler, den das ans Licht gebracht hat
------------------------------------------

x-ui.chart steht überall unter wire:ignore, sonst zerstört Livewire das Canvas.
Ein Poll erreicht den Chart also nie. Dafür bekam das Bauteil ein optionales
update-on: es hört auf ein Fenster-Ereignis und tauscht die Daten IM
bestehenden Chart.js-Objekt.

Das lief nicht. Die Zahlen neben der Kurve wanderten, die Kurve nicht, und
chart.update() starb still im Legenden-Plugin:

    TypeError: Cannot set properties of undefined (setting 'fullSize')

Grund: die Chart.js-Instanz lag als Eigenschaft im Alpine-Objekt und wurde
damit reaktiv umhüllt. Chart.js' Plugin-Innenleben überlebt das Proxy nicht.
Gemessen statt geschlossen: dieselbe Instanz wirft über das Proxy und läuft
über Alpine.raw(). Sie liegt jetzt in der Closure.

Aufgefallen ist es nie, weil bis zum ersten Live-Chart kein einziger Chart in
diesem Repo je update() gerufen hat — konstruieren und Erstzeichnen gehen durch
die Hülle noch. tests/Feature/ChartLiveUpdateTest.php hält die Regel fest,
damit der nächste Live-Chart nicht denselben Nachmittag kostet.

Der Rest
--------

- Version lesbar: "Proxmox VE 9.2.6" statt pve-manager/9.2.6/7f8d…, mitten in
  der Bau-Kennung abgeschnitten. Die Kennung steht klein darunter. Eine
  unerwartete Form wird unverändert durchgereicht statt verschluckt.
- Vier Kleinkarten (Mgmt-IP, Node, Version, Instanzen) sind eine
  Ausstattungs-Tafel geworden. Die Instanzen-Anzahl steht in der Überschrift
  der Liste, die sie ohnehin zeigt.
- Der Übernahme-Fortschritt klappt zu, sobald sie durch ist. Fünfzehn
  abgehakte Schritte sind auf einem laufenden Host kein Dauerinhalt —
  aufklappbar über <details>, ohne JavaScript.
- PlanVersion::requiredTemplateVmids() ersetzt die dritte Kopie derselben
  Fensterlogik.
- BuildVmTemplate sagt nicht mehr "this takes 10–20 minutes". Das war eine
  Schätzung vor dem ersten Lauf; gemessen waren es unter zwei. Eine Konsole,
  die falsch vorhersagt, erzieht dazu, sie zu ignorieren.

Geprüft: 2281 Tests grün, Pint sauber, Codex ohne Befund. Die Farbwahl gegen
den Validator gerechnet (ΔE 28,3 protan / 39,2 normal; der Akzent liegt unter
3:1 gegen die Fläche, deshalb tragen beide Reihen sichtbare Beschriftung). Im
Browser: null Konsolenfehler über einen vollen Poll-Zyklus, und die Kurve
wandert auf dem echten Poll ohne Neuladen — mit eingespielten Messwerten
belegt, weil die Testhosts in TEST-NET liegen und nie antworten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/hostnamen-vergabe v1.3.91
nexxo 2026-08-01 10:24:21 +02:00
parent 5aedef2b35
commit 665a0c4e08
17 changed files with 779 additions and 46 deletions

View File

@ -1 +1 @@
1.3.90
1.3.91

View File

@ -5,6 +5,9 @@ namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Proxmox\HostLoadSeries;
use App\Support\PveVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
@ -27,6 +30,26 @@ class HostDetail extends Component
$this->host->refresh();
}
/**
* The polled tick: re-read the host and push fresh points into the live
* chart.
*
* The dispatch is the only way the curve moves. The canvas sits under
* wire:ignore, so re-rendering which this method also causes updates
* every figure on the page EXCEPT the chart. See x-ui.chart's `update-on`.
*/
public function refreshLoad(): void
{
$this->host->refresh();
$load = app(HostLoadSeries::class)->forHost($this->host);
$this->dispatch(
'host-load',
labels: $this->clockLabels($load['labels']),
datasets: [$load['cpu'], $load['ram']],
);
}
/** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void
{
@ -97,6 +120,85 @@ class HostDetail extends Component
return $steps;
}
/**
* Unix seconds the wall clock the operator is reading it on (R19).
*
* @param array<int, int> $timestamps
* @return array<int, string>
*/
private function clockLabels(array $timestamps): array
{
return array_map(
fn (int $t) => Carbon::createFromTimestamp($t)->local()->format('H:i'),
$timestamps,
);
}
/**
* Chart.js config for the load curve.
*
* ONE axis, both series in percent. Two measures on two y-scales is the
* single most misread thing a chart can do, and here it is not even
* tempting: "how full is this host" is the same question for cores and for
* memory, so they belong on the same 0100.
*
* `token:accent` then `token:info` the same fixed order every other chart
* in the console uses, so a series never changes colour between pages.
* Validated as a pair: ΔE 28.3 under protanopia, 39.2 in normal vision.
* Accent falls below 3:1 against the surface, which obliges visible labels
* rather than colour alone the panel carries both current values as
* labelled figures above the canvas, and the legend names both series.
*
* @param array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool} $load
* @return array<string, mixed>
*/
private function chartConfig(array $load): array
{
$line = fn (string $label, array $data, string $token) => [
'label' => $label,
'data' => $data,
'borderColor' => 'token:'.$token,
'backgroundColor' => 'token:'.$token.'/0.10',
'borderWidth' => 2,
'pointRadius' => 0,
'pointHitRadius' => 12,
'tension' => 0.3,
'fill' => true,
// A gap stays a gap. Chart.js would otherwise draw a straight line
// across a sample Proxmox never recorded — inventing the very
// minutes we know nothing about.
'spanGaps' => false,
];
return [
'type' => 'line',
'data' => [
'labels' => $this->clockLabels($load['labels']),
'datasets' => [
$line(__('hosts.detail.cpu'), $load['cpu'], 'accent'),
$line(__('hosts.detail.ram'), $load['ram'], 'info'),
],
],
'options' => [
// One shared tooltip for the whole minute rather than one per
// line: the question is always "what were both doing then".
'interaction' => ['mode' => 'index', 'intersect' => false],
'plugins' => ['legend' => ['display' => true, 'position' => 'bottom', 'labels' => ['boxWidth' => 8, 'boxHeight' => 8, 'usePointStyle' => true]]],
'scales' => [
'y' => [
// Pinned to 0100 on purpose: an auto-scaled axis makes
// 3 % look like a wall of load.
'min' => 0,
'max' => 100,
'ticks' => ['stepSize' => 25],
'grid' => ['color' => 'token:border'],
],
'x' => ['grid' => ['display' => false], 'ticks' => ['maxTicksLimit' => 6]],
],
],
];
}
public function render()
{
$run = $this->currentRun();
@ -106,12 +208,17 @@ class HostDetail extends Component
? $run->events()->latest('id')->limit(30)->get()
: collect();
$load = app(HostLoadSeries::class)->forHost($this->host);
return view('livewire.admin.host-detail', [
'run' => $run,
'steps' => $this->buildSteps($run),
'events' => $events,
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
'load' => $load,
'loadChart' => $this->chartConfig($load),
'version' => PveVersion::parse($this->host->pve_version),
]);
}
}

View File

@ -232,7 +232,10 @@ class BuildVmTemplate extends HostStep
'i=0; while [ "$i" -lt 15 ] && [ ! -s "$W/pid" ]; do sleep 1; i=$((i + 1)); done',
]));
return StepResult::poll(30, 'building the VM template (this takes 1020 minutes)');
// No time estimate here any more. "1020 minutes" was a guess written
// before this had ever run; the first real build took under two, and a
// console that predicts wrongly teaches an operator to ignore it.
return StepResult::poll(30, 'building the VM template');
}
/** @param array{state:string, alive:string, phase:string, note:string} $status */

View File

@ -192,6 +192,19 @@ class FakeProxmoxClient implements ProxmoxClient
$this->networkRates[$vmid] = $mbytesPerSecond;
}
/**
* The node's recorded history, as PVE would hand it back. Empty by default:
* a host nobody scripted samples for has none.
*
* @var array<int, array<string, mixed>>
*/
public array $rrd = [];
public function nodeRrdData(string $node, string $timeframe = 'hour'): array
{
return $this->rrd;
}
public function vmExists(string $node, int $vmid): bool
{
return in_array($vmid, $this->clonedVmids, true) || in_array($vmid, $this->runningVmids, true);

View File

@ -0,0 +1,92 @@
<?php
namespace App\Services\Proxmox;
use App\Models\Host;
use Illuminate\Support\Facades\Cache;
use Throwable;
/**
* The last hour of a host's CPU and memory load, as two percentage series.
*
* Read from Proxmox's OWN recorded history (`/nodes/{node}/rrddata`) rather
* than sampled into a table of ours. PVE keeps hour/day/week/month/year for
* every node whether we ask or not, so a sampler would mean a new table, a
* minutely job, a pruning job and ~1440 rows per host per day to reproduce,
* less accurately, something already on disk. It also means the curve is full
* from the first second, including the hour before anyone opened the page, and
* that it cannot disagree with what Proxmox's own interface shows.
*
* The price is stated rather than hidden: a host that is not reachable has no
* curve. See `available` below.
*/
final class HostLoadSeries
{
/** Just under the minute at which PVE writes a fresh sample. */
private const TTL_SECONDS = 55;
public function __construct(private ProxmoxClient $pve) {}
/**
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool}
*/
public function forHost(Host $host): array
{
return Cache::remember(
'host-load:'.$host->id,
self::TTL_SECONDS,
fn () => $this->read($host),
);
}
/**
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool}
*/
private function read(Host $host): array
{
$empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'available' => false];
try {
$rows = $this->pve->forHost($host)->nodeRrdData($host->node ?? 'pve', 'hour');
} catch (Throwable) {
// Deliberately not a series of zeros. A flat line at zero is a
// claim about the host — that it was idle — and this is the one
// case where we know nothing at all. The panel says so instead.
return $empty;
}
if ($rows === []) {
return $empty;
}
$labels = [];
$cpu = [];
$ram = [];
foreach ($rows as $row) {
$labels[] = (int) ($row['time'] ?? 0);
$cpu[] = $this->percent($row['cpu'] ?? null, 1);
$ram[] = $this->percent($row['memused'] ?? null, $row['memtotal'] ?? null);
}
return ['labels' => $labels, 'cpu' => $cpu, 'ram' => $ram, 'available' => true];
}
/**
* A share of a whole, in percent or null when either half is missing.
*
* Null rather than 0.0, and that is the whole care taken here: Proxmox
* simply omits the fields for a sample it has no data for, and charting
* that omission as zero draws a quiet hour the host never had. The same
* distinction `instance_metrics` makes for its nullable columns, for the
* same reason.
*/
private function percent(mixed $part, mixed $whole): ?float
{
if (! is_numeric($part) || ! is_numeric($whole) || (float) $whole <= 0.0) {
return null;
}
return round((float) $part / (float) $whole * 100, 2);
}
}

View File

@ -114,6 +114,16 @@ class HttpProxmoxClient implements ProxmoxClient
->throw()->json('data');
}
public function nodeRrdData(string $node, string $timeframe = 'hour'): array
{
// AVERAGE, not MAX: MAX turns a single busy second into a plateau that
// reads like sustained load. The console's question is "how busy is
// this host", and the average is the honest answer to it.
return $this->http()
->get("/nodes/{$node}/rrddata", ['timeframe' => $timeframe, 'cf' => 'AVERAGE'])
->throw()->json('data', []);
}
public function vmStatus(string $node, int $vmid): array
{
return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->throw()->json('data', []);

View File

@ -66,6 +66,17 @@ interface ProxmoxClient
*/
public function shutdownVm(string $node, int $vmid, int $timeoutSeconds): string;
/**
* Proxmox's own recorded history for a node cpu, memused, memtotal, per
* sample, oldest first.
*
* $timeframe is one of hour|day|week|month|year; PVE keeps all five, so a
* chart never has to be fed from a sampler of ours.
*
* @return array<int, array<string, mixed>>
*/
public function nodeRrdData(string $node, string $timeframe = 'hour'): array;
/** @return array<string, mixed> ['status' => 'running'|'stopped', …] */
public function vmStatus(string $node, int $vmid): array;

View File

@ -0,0 +1,40 @@
<?php
namespace App\Support;
/**
* Splits what Proxmox calls itself into the part a human reads and the part
* almost nobody does.
*
* `pve-manager/9.2.6/7f8d010005bd7231` shown raw is worse than useless in a
* narrow card: the string is truncated somewhere inside the build hash, so the
* console displayed everything EXCEPT the version number the only part of it
* anyone looks for.
*/
final class PveVersion
{
/**
* @return array{version: ?string, build: ?string}
*/
public static function parse(?string $raw): array
{
$raw = trim((string) $raw);
if ($raw === '') {
return ['version' => null, 'build' => null];
}
// pve-manager/<version>/<build>. The build is optional — some hosts
// report only the first two segments.
if (preg_match('#^[a-z-]+/([0-9][0-9.]*)(?:/([0-9a-f]+))?$#i', $raw, $m) === 1) {
return ['version' => $m[1], 'build' => $m[2] ?? null];
}
// An unexpected shape is passed through whole rather than swallowed. A
// display that shows nothing when it does not recognise something is
// worse than one that hands over what it was given: the operator can
// still read it, and nobody is left wondering whether the field is
// empty or the parser gave up.
return ['version' => $raw, 'build' => null];
}
}

View File

@ -0,0 +1,98 @@
# Host-Detailseite: Last statt Ausstattung
*Entwurf, 1. August 2026*
## Der Befund
Die Karte **„Rechenleistung"** zeigt keine Leistung. `12 Kerne / 63 GB` ist die
Ausstattung des Blechs und ändert sich nie — sie steht aber im selben
Kartenraster wie „Zustand" und „Speicher", die beide leben. Wer die Seite
öffnet, um zu sehen, wie es dem Host geht, liest dort eine Zahl, die nie etwas
darüber sagt.
Zwei kleinere Dinge derselben Art: die Version steht als
`pve-manager/9.2.6/7f8d010005bd72…` und wird mitten in der Bau-Kennung
abgeschnitten — sichtbar ist alles außer der Zahl, um die es geht. Und der
Übernahme-Fortschritt hält auf einem seit Wochen laufenden Host fünfzehn
abgehakte Schritte in voller Höhe offen.
## 1. Woher die Kurve kommt
`GET /nodes/{node}/rrddata?timeframe=hour&cf=AVERAGE`.
Proxmox zeichnet CPU, Speicher, Netz und I/O-Wait ohnehin auf — in Stunde, Tag,
Woche, Monat und Jahr. Ein eigener Sampler hieße: neue Tabelle, minütlicher Job,
Aufräum-Job, ~1440 Zeilen je Host und Tag, und eine Kurve, die am Anfang leer
ist. Die RRD ist ab der ersten Sekunde gefüllt, auch für die Stunde, bevor
jemand hingesehen hat, und es sind dieselben Zahlen wie in Proxmox' eigener
Oberfläche — eine Abweichung zwischen beiden kann nicht entstehen.
Der Preis ist bekannt und wird getragen: ist der Host nicht erreichbar, gibt es
keine Kurve. Das ist ehrlicher als eine aus unserer Datenbank nachgezeichnete.
**`App\Services\Proxmox\HostLoadSeries`** rechnet die Antwort in zwei
Prozentreihen um: `cpu * 100` und `memused / memtotal * 100`.
**Eine Lücke bleibt eine Lücke.** Ein Punkt ohne Messwert wird `null`, nie `0`
dieselbe Regel, die in `instance_metrics` schon begründet steht: „eine Messung,
die scheiterte, muss von einer Messung mit dem Wert null unterscheidbar sein."
Eine Null-Linie behauptet einen ruhigen Host; eine Lücke behauptet nichts.
Antwortet der Host gar nicht, ist die Reihe leer und die Tafel sagt das in einem
Satz.
**Abgefragt wird im Minutentakt**, weil die RRD selbst im Minutentakt
fortgeschrieben wird. Alles darunter wären Anfragen über den Tunnel für dieselbe
Antwort. Dazu `Cache::remember` für 55 s je Host, damit zwei offene Konsolen den
Host nicht doppelt fragen.
## 2. Die Kurve wirklich lebendig
Die eigentliche Hürde: `x-ui.chart` steht überall unter `wire:ignore` — sonst
zerstört Livewire bei jedem Rendern das Canvas — und die Alpine-Komponente baut
den Chart **einmal** in `init()`. Einen Aktualisierungsweg gibt es nicht, also
würde ein Poll die Zahlen daneben erneuern und die Kurve stehenlassen.
`x-ui.chart` bekommt deshalb ein optionales **`update-on="<ereignis>"`**. Ist es
gesetzt, hört die Komponente auf dieses Fenster-Ereignis, tauscht Labels und
Datenreihen **im bestehenden Chart.js-Objekt** aus und ruft `update('none')`
ohne Animation, sonst zuckt die Kurve jede Minute neu auf. Livewire schickt nach
jedem Poll `$this->dispatch('host-load', …)`.
Eine Ergänzung am Designsystem, die jeder künftige Live-Chart erbt, statt einer
Sonderlösung auf dieser einen Seite.
## 3. Die Seite nach lebendig und fest
Heute sehen beide gleich aus, und deshalb wirkt eine Ausstattungszahl wie eine
Messung.
| | heute | danach |
|---|---|---|
| **Lebendig** | Zustand, Speicher, „Rechenleistung" | Zustand, **Last** (CPU + RAM, Stundenkurve), Speicher |
| **Fest** | vier Kleinkarten: Mgmt-IP, Node, Version, Instanzen | **eine Ausstattungs-Tafel**: Kerne, RAM, Node, Mgmt-IP, Version |
| **Instanzen** | eigene Kleinkarte mit „0" | als Anzahl in die Überschrift von „Gehostete Instanzen", wo die Liste ohnehin steht |
| **Version** | abgeschnitten | **Proxmox VE 9.2.6**, Bau-Kennung klein darunter |
| **Fortschritt** | 15 abgehakte Schritte, dauerhaft | während der Übernahme offen; ist sie durch, eine Zeile zum Aufklappen (`<details>`, kein JS) |
Die Versionszeichenkette wird von `App\Support\PveVersion` zerlegt
(`pve-manager/9.2.6/7f8d…` → `9.2.6` + `7f8d…`). Passt die Form nicht, wird die
Zeichenkette unverändert gezeigt — eine Anzeige, die an einer unerwarteten Form
lieber nichts zeigt, ist schlechter als eine, die das Rohe durchreicht.
## Prüfung
- `HostLoadSeries`: Prozentrechnung aus echten RRD-Zeilen; ein Punkt ohne
`memtotal` wird `null` und nicht `0`; leere Antwort und geworfene Ausnahme
ergeben beide eine leere Reihe, nie Nullen; der Zwischenspeicher fragt den
Host nicht zweimal.
- `PveVersion`: die erwartete Form, eine unerwartete, eine leere.
- Seite: Last-Tafel vorhanden, „Proxmox VE 9.2.6" lesbar, Stepper offen während
des Laufs und zugeklappt danach, Instanzen-Anzahl in der Überschrift.
- R12: null Konsolenfehler auf `/admin/hosts/{uuid}`.
## Was hier nicht passiert
- **Kein Flottenbild über alle Hosts.** Die RRD-Wahl schließt es nicht aus, aber
es ist eine eigene Aufgabe mit einer eigenen Frage dahinter.
- **Kein Alarm bei Dauerlast.** Überwachung liegt bei Uptime Kuma; eine zweite
Stelle, die Schwellen kennt, wäre eine zweite Wahrheit.

View File

@ -76,6 +76,14 @@ return [
'health' => 'Zustand',
'storage' => 'Speicher',
'compute' => 'Rechenleistung',
'load' => 'Last',
'load_hint' => 'letzte Stunde',
'no_load' => 'Keine Messwerte — der Host antwortet gerade nicht.',
'cpu' => 'CPU',
'ram' => 'RAM',
'spec' => 'Ausstattung',
'build' => 'Bau',
'onboarding_done' => 'Übernahme abgeschlossen',
'node' => 'Node',
'instances' => 'Instanzen',
'hosted' => 'Gehostete Instanzen',

View File

@ -74,6 +74,14 @@ return [
'detail' => [
'health' => 'Health',
'load' => 'Load',
'load_hint' => 'last hour',
'no_load' => 'No measurements — the host is not answering right now.',
'cpu' => 'CPU',
'ram' => 'RAM',
'spec' => 'Specification',
'build' => 'Build',
'onboarding_done' => 'Onboarding complete',
'storage' => 'Storage',
'compute' => 'Compute',
'node' => 'Node',

View File

@ -523,8 +523,23 @@ document.addEventListener('alpine:init', () => {
}));
// ── Chart.js island ──────────────────────────────────────────────────
window.Alpine.data('chart', (config) => ({
instance: null,
// Die Chart.js-Instanz liegt BEWUSST in der Closure und nicht als
// Eigenschaft des zurückgegebenen Objekts.
//
// Alles, was Alpine.data() zurückgibt, wird reaktiv — also in ein Proxy
// gehüllt. Chart.js' Plugin-Innenleben überlebt das nicht: `chart.update()`
// stirbt in der Legende mit "Cannot set properties of undefined (setting
// 'fullSize')". Konstruieren und Erstzeichnen gehen durch die Hülle noch,
// deshalb ist es jahrelang nicht aufgefallen — bis zum Live-Chart hat kein
// Chart in diesem Repo je update() gerufen.
//
// Gemessen, nicht vermutet: dieselbe Instanz wirft über das Proxy und läuft
// über Alpine.raw(). Erzwungen durch tests/Feature/ChartLiveUpdateTest.php.
window.Alpine.data('chart', (config, updateOn = null) => {
let instance = null;
let onFresh = null;
return {
init() {
// Read tokens from this element so charts honour a scoped theme
// (e.g. .theme-admin dark tokens), not just :root.
@ -541,12 +556,45 @@ document.addEventListener('alpine:init', () => {
animation: prefersReducedMotion() ? false : cfg.options?.animation,
...cfg.options,
};
this.instance = new Chart(this.$refs.canvas, cfg);
instance = new Chart(this.$refs.canvas, cfg);
// Live charts. A chart under wire:ignore never sees a re-render, so
// without this a polling page updates every figure around the
// canvas and leaves the curve where it was an hour ago.
if (updateOn) {
onFresh = (e) => this.apply(e.detail);
window.addEventListener(updateOn, onFresh);
}
},
// Swap the data inside the live instance rather than rebuilding it:
// rebuilding would drop the hovered tooltip and re-run the entry
// animation on every tick.
apply(detail) {
const fresh = Array.isArray(detail) ? detail[0] : detail;
if (!instance || !fresh) return;
if (fresh.labels) instance.data.labels = fresh.labels;
// Positional: dataset 0 stays dataset 0, so a series keeps its
// colour across updates. Colour follows the entity, never its
// position in whatever came back.
(fresh.datasets || []).forEach((points, i) => {
if (instance.data.datasets[i]) instance.data.datasets[i].data = points;
});
// 'none' — no animation. Re-animating every minute makes a calm
// host look like a busy one out of the corner of an eye.
instance.update('none');
},
destroy() {
this.instance?.destroy();
if (onFresh && updateOn) window.removeEventListener(updateOn, onFresh);
instance?.destroy();
instance = null;
},
}));
};
});
// ── Connection banner ──────────────────────────────────────────────────
// "Keine Verbindung" while offline, "Verbindung wiederhergestellt" for a

View File

@ -3,9 +3,18 @@
// "token:accent/0.12" strings, resolved from CSS design tokens at runtime.
'config',
'label' => null,
// Optional: the name of a browser event carrying fresh data for this chart.
//
// Charts sit under wire:ignore — otherwise Livewire destroys the canvas on
// every render — so no amount of polling reaches them, and a live page
// would refresh the figures beside the chart while the curve stood still.
// With this set, the component swaps labels and datasets INSIDE the
// existing Chart.js instance instead. Livewire feeds it with
// $this->dispatch('<name>', labels: [...], datasets: [[...], [...]]).
'updateOn' => null,
])
<div
x-data="chart(@js($config))"
x-data="chart(@js($config), @js($updateOn))"
@if ($label) role="img" aria-label="{{ $label }}" @endif
{{ $attributes->merge(['class' => 'relative']) }}
>

View File

@ -1,4 +1,7 @@
<div class="space-y-5" @if ($run && in_array($run->status, ['pending', 'running', 'waiting'])) wire:poll.4s @endif>
{{-- Zwei Taktungen, ein Aufruf: während der Übernahme zählt jede Sekunde, danach
schreibt Proxmox seine Messwerte ohnehin nur einmal pro Minute fort. Der
Zwischenspeicher in HostLoadSeries hält die schnelle Taktung vom Host fern. --}}
<div class="space-y-5" @if ($run && in_array($run->status, ['pending', 'running', 'waiting'])) wire:poll.4s="refreshLoad" @else wire:poll.60s="refreshLoad" @endif>
@php
$badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info';
@endphp
@ -47,7 +50,11 @@
$htone = ['online' => 'text-success', 'stale' => 'text-warning', 'offline' => 'text-danger'][$health];
$usedPct = $host->usedPct();
@endphp
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise">
{{-- Vier Spalten, die Last belegt zwei: eine Zeitreihe braucht Breite. Auf
einem Drittel der Zeile blieben nach Legende und Zeitachse rund dreißig
Pixel Zeichenfläche, und eine Kurve, die man nicht lesen kann, ist
schlimmer als die Zahl, die vorher dort stand. --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-4 animate-rise">
{{-- Health --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.health') }}</p>
@ -68,52 +75,103 @@
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
</div>
<div class="mt-2 flex items-center gap-2" x-data="{ r: {{ $host->reserve_pct }} }">
<span class="text-xs text-muted">{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve_label') }}</span>
<input type="number" min="0" max="90" x-model.number="r" aria-label="{{ __('hosts.detail.reserve_label') }}"
class="w-16 rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-ink" />
<span class="text-xs text-muted">%</span>
<button type="button" x-on:click="$wire.saveReserve(r)" class="rounded-md border border-line px-2 py-1 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">{{ __('hosts.detail.reserve_save') }}</button>
{{-- Belegung auf eine eigene Zeile über der Eingabe: in der
schmaleren Spalte brach der frühere Einzeiler mitten im
Satz um, und „· Reserve" stand allein unter der Zahl. --}}
<div class="mt-2" x-data="{ r: {{ $host->reserve_pct }} }">
<p class="text-xs text-muted">{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }}</p>
<div class="mt-1.5 flex items-center gap-2">
<span class="text-xs text-muted">{{ __('hosts.detail.reserve_label') }}</span>
<input type="number" min="0" max="90" x-model.number="r" aria-label="{{ __('hosts.detail.reserve_label') }}"
class="w-16 rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-ink" />
<span class="text-xs text-muted">%</span>
<button type="button" x-on:click="$wire.saveReserve(r)" class="rounded-md border border-line px-2 py-1 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">{{ __('hosts.detail.reserve_save') }}</button>
</div>
</div>
@else
<p class="mt-2 text-sm text-muted">{{ __('hosts.unknown') }}</p>
@endif
</div>
{{-- Compute --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.compute') }}</p>
<div class="mt-2 grid grid-cols-2 gap-3">
<div>
<p class="font-mono text-lg font-semibold text-ink">{{ $host->cpu_cores ?? '—' }}</p>
<p class="text-xs text-muted">{{ __('hosts.meta.cores') }}</p>
</div>
<div>
<p class="font-mono text-lg font-semibold text-ink">{{ $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : '—' }}</p>
<p class="text-xs text-muted">{{ __('hosts.meta.ram') }}</p>
</div>
{{-- Load. Die Karte, die vorher „Rechenleistung" hieß und die
Ausstattung zeigte: 12 Kerne ändern sich nie und sagten nichts
darüber, wie es dem Host geht. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs lg:col-span-2">
<div class="flex items-baseline justify-between gap-2">
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.load') }}</p>
<p class="text-xs text-muted">{{ __('hosts.detail.load_hint') }}</p>
</div>
@if ($load['available'])
@php
// Der jüngste Messwert, der wirklich einer ist: die letzten
// Punkte können Lücken sein, und eine Lücke ist keine Null.
$lastOf = fn (array $s) => collect($s)->filter(fn ($v) => $v !== null)->last();
$cpuNow = $lastOf($load['cpu']);
$ramNow = $lastOf($load['ram']);
@endphp
{{-- Beschriftete Zahlen, nicht nur Farbe: der Akzentton liegt
unter 3:1 gegen die Fläche, und die Kurve allein dürfte die
Identität der Reihen deshalb nicht tragen. --}}
<div class="mt-2 flex items-baseline gap-5">
<p class="font-mono text-lg font-semibold text-ink">
{{ $cpuNow !== null ? round($cpuNow).' %' : '—' }}
<span class="ml-1 text-xs font-normal text-muted">{{ __('hosts.detail.cpu') }}</span>
</p>
<p class="font-mono text-lg font-semibold text-ink">
{{ $ramNow !== null ? round($ramNow).' %' : '—' }}
<span class="ml-1 text-xs font-normal text-muted">{{ __('hosts.detail.ram') }}</span>
</p>
</div>
<div class="mt-3 h-36" wire:ignore>
<x-ui.chart :config="$loadChart" update-on="host-load" class="h-36"
:label="__('hosts.detail.load').' — '.__('hosts.detail.load_hint')" />
</div>
@else
<p class="mt-2 text-sm text-muted">{{ __('hosts.detail.no_load') }}</p>
@endif
</div>
</div>
{{-- Technical facts --}}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 animate-rise [animation-delay:60ms]">
@foreach ([
['meta.wg_ip', $host->wg_ip ?? __('hosts.unknown')],
['detail.node', $host->node ?? __('hosts.unknown')],
['meta.version', $host->pve_version ?? __('hosts.unknown')],
['detail.instances', (string) $instances->count()],
] as [$key, $value])
<div class="rounded-lg border border-line bg-surface p-4 shadow-xs">
<p class="text-xs text-muted">{{ __('hosts.'.$key) }}</p>
<p class="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p>
{{-- Ausstattung: was feststeht. Vorher standen diese Zahlen in Karten, die
wie die lebendigen aussahen deshalb las sich eine Kernzahl wie eine
Messung. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="mb-3 text-xs font-semibold text-muted">{{ __('hosts.detail.spec') }}</h2>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-5">
@foreach ([
[__('hosts.meta.cores'), $host->cpu_cores ? (string) $host->cpu_cores : '—'],
[__('hosts.meta.ram'), $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : '—'],
[__('hosts.detail.node'), $host->node ?? __('hosts.unknown')],
[__('hosts.meta.wg_ip'), $host->wg_ip ?? __('hosts.unknown')],
] as [$label, $value])
<div class="min-w-0">
<dt class="text-xs text-muted">{{ $label }}</dt>
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</dd>
</div>
@endforeach
<div class="min-w-0">
<dt class="text-xs text-muted">{{ __('hosts.meta.version') }}</dt>
@if ($version['version'])
{{-- Die Zahl, die jemand sucht, statt der Bau-Kennung, in der
sie vorher abgeschnitten wurde. --}}
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">Proxmox VE {{ $version['version'] }}</dd>
@if ($version['build'])
<dd class="truncate font-mono text-[11px] text-muted" title="{{ $host->pve_version }}">{{ __('hosts.detail.build') }} {{ $version['build'] }}</dd>
@endif
@else
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">{{ __('hosts.unknown') }}</dd>
@endif
</div>
@endforeach
</dl>
</div>
{{-- Hosted instances --}}
{{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene
Kleinkarte neben der Liste, die sie ohnehin zeigt. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
<h2 class="mb-3 text-sm font-semibold text-ink">{{ __('hosts.detail.hosted') }}</h2>
<h2 class="mb-3 text-sm font-semibold text-ink">
{{ __('hosts.detail.hosted') }}
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ $instances->count() }}</span>
</h2>
@if ($instances->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.detail.no_instances') }}</p>
@else
@ -145,13 +203,38 @@
</div>
@endif
@php
// Zugeklappt, sobald die Übernahme durch ist: fünfzehn abgehakte
// Schritte sind auf einem seit Wochen laufenden Host kein Dauerinhalt.
// Aufklappbar, nicht weg — wer nachsehen will, warum ein Schritt
// wiederholt wurde, findet ihn noch.
$onboardingDone = $run && $run->status === \App\Models\ProvisioningRun::STATUS_COMPLETED;
@endphp
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3">
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2>
@if ($run)
<x-ui.progress-stepper :steps="$steps" />
@if ($onboardingDone)
{{-- <details> statt Alpine: ein Aufklapper braucht kein
JavaScript, und der Inhalt bleibt für die Suche im Browser
und für Screenreader erreichbar. --}}
<details class="group">
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-semibold text-ink">
<x-ui.icon name="check" class="size-4 shrink-0 text-success" />
<span>{{ __('hosts.detail.onboarding_done') }}</span>
<span class="ml-auto font-mono text-xs font-normal text-muted">
{{ $run->updated_at?->local()->isoFormat('D. MMM, HH:mm') }}
</span>
</summary>
<div class="mt-4">
<x-ui.progress-stepper :steps="$steps" />
</div>
</details>
@else
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2>
@if ($run)
<x-ui.progress-stepper :steps="$steps" />
@else
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
@endif
@endif
</div>

View File

@ -309,3 +309,63 @@ it('treats a drained host as running, not as one waiting for takeover', function
expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id)
->and(ReissueTakeover::eligible($host))->toBeFalse();
});
// --- Detailseite: lebendig und fest getrennt ---------------------------------
it('shows the version number rather than the build hash it is buried in', function () {
// In der schmalen Karte wurde `pve-manager/9.2.6/7f8d…` mitten in der
// Bau-Kennung abgeschnitten — sichtbar war alles außer der Zahl, um die es
// geht.
fakeServices();
$host = Host::factory()->active()->create(['pve_version' => 'pve-manager/9.2.6/7f8d010005bd7231']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee('Proxmox VE 9.2.6');
});
it('draws the load curve from what proxmox recorded', function () {
$s = fakeServices();
$s['pve']->rrd = [
['time' => 1754035200, 'cpu' => 0.25, 'memused' => 34_359_738_368, 'memtotal' => 68_719_476_736],
['time' => 1754035260, 'cpu' => 0.5, 'memused' => 17_179_869_184, 'memtotal' => 68_719_476_736],
];
$host = Host::factory()->active()->create(['node' => 'pve-fns-1']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.load'))
// Die aktuelle Auslastung, nicht die Ausstattung: der letzte Messwert.
->assertSee('50')
->assertSee('25');
});
it('says a silent host has no measurements instead of drawing a quiet hour', function () {
// Der ganze Grund, warum HostLoadSeries eine leere Reihe liefert statt
// Nullen: eine Null-Linie behauptet, der Host habe nichts zu tun gehabt.
fakeServices(); // rrd bleibt leer
$host = Host::factory()->active()->create();
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.no_load'));
});
it('folds the finished onboarding away but keeps it reachable', function () {
fakeServices();
$host = Host::factory()->active()->create();
ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_COMPLETED]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
// Fünfzehn abgehakte Schritte sind auf einem laufenden Host kein
// Dauerinhalt — aufklappbar, nicht weg.
->assertSee('<details', false)
->assertSee(__('hosts.detail.onboarding_done'));
});
it('keeps the stepper open while the onboarding is still running', function () {
fakeServices();
$host = Host::factory()->create(['status' => 'onboarding']);
ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_RUNNING]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertDontSee('<details', false)
->assertSee(__('hosts.progress'));
});

View File

@ -0,0 +1,51 @@
<?php
/**
* Die Chart.js-Instanz darf nicht im reaktiven Alpine-Objekt liegen.
*
* Gefunden am 1. August 2026 am ersten Live-Chart der Konsole: die Zahlen neben
* der Kurve wanderten, die Kurve nicht. `chart.update()` starb still im
* Legenden-Plugin mit
*
* TypeError: Cannot set properties of undefined (setting 'fullSize')
*
* Alles, was `Alpine.data()` zurückgibt, wird reaktiv also in ein Proxy
* gehüllt. Chart.js' Plugin-Innenleben überlebt das nicht. Gemessen im Browser,
* nicht geschlossen: dieselbe Instanz wirft über das Proxy und läuft über
* `Alpine.raw()`.
*
* Warum das jahrelang niemandem auffiel: bis zum Live-Chart rief kein einziger
* Chart in diesem Repo je `update()`. Konstruieren und Erstzeichnen gehen durch
* die Hülle noch. Wer den nächsten Live-Chart baut, würde denselben Nachmittag
* verlieren deshalb steht die Regel hier und nicht nur im Kommentar.
*
* Eine Textprüfung, weil dieses Repo keine JS-Testumgebung hat. Sie prüft die
* eine Zeile, an der die Entscheidung hängt.
*/
it('keeps the chart instance out of alpine reactive state', function () {
$js = file_get_contents(base_path('resources/js/app.js'));
$start = strpos($js, "Alpine.data('chart'");
expect($start)->not->toBeFalse('Die Alpine-Komponente `chart` gibt es nicht mehr — dieser Test braucht einen neuen Anker.');
// Der Rumpf bis zur nächsten Alpine-Komponente.
$next = strpos($js, 'Alpine.data(', $start + 20);
$body = substr($js, $start, $next === false ? 4000 : $next - $start);
expect($body)
// In der Closure, nicht am Objekt.
->toContain('let instance = null')
// `instance: null` als Objekt-Eigenschaft ist genau der Rückfall.
->not->toMatch('/^\s*instance:\s/m')
->not->toContain('this.instance');
});
it('still offers the live update path the host page depends on', function () {
$js = file_get_contents(base_path('resources/js/app.js'));
$blade = file_get_contents(base_path('resources/views/components/ui/chart.blade.php'));
// Ohne diesen Weg zeigt die Lastkurve für immer die Werte des Seitenaufrufs.
expect($js)->toContain('window.addEventListener(updateOn')
->and($js)->toContain("instance.update('none')")
->and($blade)->toContain('updateOn');
});

View File

@ -5,11 +5,13 @@ use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Services\Dns\FileHostDnsDirectory;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\HostLoadSeries;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Ssh\CommandResult;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\LocalWireguardHub;
use App\Support\PveVersion;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
@ -160,6 +162,96 @@ it('reports an ordinary virtual machine carrying the vmid as no template', funct
expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse();
});
// --- HostLoadSeries ---------------------------------------------------------
//
// Die Stundenkurve auf der Host-Seite. Proxmox zeichnet CPU und Speicher
// ohnehin auf; hier wird daraus nur gerechnet — und die eine Regel eingehalten,
// die eine Messreihe ehrlich hält: eine Lücke ist keine Null.
function rrdRow(int $time, ?float $cpu, ?int $memused, ?int $memtotal): array
{
return array_filter([
'time' => $time,
'cpu' => $cpu,
'memused' => $memused,
'memtotal' => $memtotal,
], fn ($v) => $v !== null);
}
it('turns proxmox rrd rows into cpu and ram percentages', function () {
Http::fake(['*/rrddata*' => Http::response(['data' => [
rrdRow(1754035200, 0.0125, 8_589_934_592, 68_719_476_736), // 1.25 % CPU, 12.5 % RAM
rrdRow(1754035260, 0.5, 34_359_738_368, 68_719_476_736), // 50 % CPU, 50 % RAM
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
expect($series['cpu'])->toBe([1.25, 50.0])
->and($series['ram'])->toBe([12.5, 50.0])
->and($series['labels'])->toHaveCount(2);
});
it('leaves a missing sample as a gap, never as zero', function () {
// Dieselbe Regel, die in instance_metrics begründet steht: eine Messung,
// die scheiterte, muss von einer Messung mit dem Wert null unterscheidbar
// sein. Eine Null-Linie behauptet einen ruhigen Host.
Http::fake(['*/rrddata*' => Http::response(['data' => [
rrdRow(1754035200, 0.25, 34_359_738_368, 68_719_476_736),
rrdRow(1754035260, null, null, null), // Proxmox lässt die Felder weg
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
expect($series['cpu'])->toBe([25.0, null])
->and($series['ram'])->toBe([50.0, null]);
});
it('reports no series at all when the host does not answer', function () {
// Nicht eine Reihe aus Nullen: der Host sagt nichts, und die Tafel muss das
// sagen dürfen statt eine ruhige Stunde zu zeichnen.
Http::fake(['*' => Http::response('gateway timeout', 502)]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
expect($series['cpu'])->toBe([])
->and($series['ram'])->toBe([])
->and($series['available'])->toBeFalse();
});
it('does not ask the host twice while the samples are still fresh', function () {
// Die RRD wird einmal pro Minute fortgeschrieben. Zwei offene Konsolen
// dürfen daraus nicht zwei Anfragen über den Tunnel machen.
Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1, 2)]])]);
$host = proxmoxHost();
app(HostLoadSeries::class)->forHost($host);
app(HostLoadSeries::class)->forHost($host);
Http::assertSentCount(1);
});
// --- PveVersion -------------------------------------------------------------
it('reads the version out of what proxmox calls itself', function () {
$v = PveVersion::parse('pve-manager/9.2.6/7f8d010005bd7231');
expect($v['version'])->toBe('9.2.6')
->and($v['build'])->toBe('7f8d010005bd7231');
});
it('shows an unexpected version string unchanged rather than nothing', function () {
// Eine Anzeige, die an einer ungewohnten Form lieber nichts zeigt, ist
// schlechter als eine, die das Rohe durchreicht.
$v = PveVersion::parse('irgendwas-anderes');
expect($v['version'])->toBe('irgendwas-anderes')
->and($v['build'])->toBeNull();
});
it('has nothing to say about a host that never reported a version', function () {
expect(PveVersion::parse(null)['version'])->toBeNull();
});
// Der Hetzner-DNS-Client steht in HetznerCloudDnsTest — seit dem Umzug auf die
// Cloud-API (RRSets statt einzelner Records) gehört dazu mehr als zwei Fälle.
// Die alte Seitenlauf-Falle, die hier stand, kann es nicht mehr geben: es gibt