Seitenleiste neu geordnet: Betrieb trug zwoelf Eintraege aus drei Themen
tests / pest (push) Failing after 9m21s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

main
nexxo 2026-08-02 00:29:14 +02:00
parent 5413628c26
commit c0f83ab957
6 changed files with 230 additions and 52 deletions

View File

@ -41,58 +41,69 @@ final class Navigation
public static function console(): array
{
return [
// Allein und ohne Überschrift: die Seite, auf der man landet.
['label' => null, 'items' => [
['admin.overview', 'gauge', 'overview', null],
]],
// Wer bei uns ist und was er hat.
['label' => __('admin.nav_group.customers'), 'items' => [
['admin.customers', 'users', 'customers', null],
['admin.instances', 'box', 'instances', null],
['admin.hosts', 'server', 'hosts', null],
['admin.datacenters', 'database', 'datacenters', null],
['admin.plans', 'tag', 'plans', 'plans.manage'],
]],
// Die Maschinen und ihre Adressen — der Bestand, den man ansieht.
['label' => __('admin.nav_group.fleet'), 'items' => [
['admin.hosts', 'server', 'hosts', null],
['admin.datacenters', 'database', 'datacenters', null],
['admin.capacity', 'database', 'capacity', 'hosts.manage'],
// Hostnamen und ihre Zertifikate: was hier steht, entscheidet,
// ob eine Adresse ANTWORTET. Kein Einrichten, sondern Bestand.
['admin.proxy-hosts', 'globe', 'proxy_hosts', 'site.manage'],
// Stand vorher unter System, mit der Begründung, der Tunnel
// müsse stehen, bevor irgendetwas geht. Das stimmt — gilt aber
// genauso für Integrationen und Bereitschaft, die dort bleiben.
// Der Unterschied: das hier ist eine LISTE VON GEGENSTELLEN,
// die man beim Arbeiten ansieht, so wie die Hostliste. Dieselbe
// Begründung, mit der Hostnamen schon aus System herauskamen.
['admin.vpn', 'shield', 'vpn', null],
]],
// Was gerade läuft oder klemmt. Vorgänge, kein Bestand.
//
// Diese Gruppe trug bis hierher zwölf Einträge — Maschinen, Geld und
// Post unter einem Wort, das auf alle drei zutrifft und deshalb
// keinem hilft. Sie heißt weiter „Betrieb" und meint jetzt nur noch
// das eine.
['label' => __('admin.nav_group.operations'), 'items' => [
['admin.provisioning', 'activity', 'provisioning', null],
['admin.maintenance', 'alert-triangle', 'maintenance', null],
['admin.incidents', 'bell', 'incidents', null],
['admin.capacity', 'database', 'capacity', 'hosts.manage'],
// Hostnamen und ihre Zertifikate. Unter Betrieb und nicht unter
// System: was hier steht, entscheidet, ob eine Adresse
// ANTWORTET — das ist kein Einrichten, das ist Betrieb. Ohne
// diesen Eintrag war die Seite zwar vorhanden, aber über die
// Navigation nicht erreichbar, und eine Seite, die man nur mit
// der URL findet, gibt es für den Betreiber nicht.
['admin.proxy-hosts', 'globe', 'proxy_hosts', 'site.manage'],
]],
// Alles, wo Geld drinsteht.
['label' => __('admin.nav_group.billing'), 'items' => [
['admin.revenue', 'trending-up', 'revenue', null],
// Its own entry, not a section of Settings: what is set here
// appears on a legal document, and burying it beside the
// site-visibility switch invites somebody to change one in passing.
['admin.finance', 'receipt', 'finance', 'site.manage'],
['admin.invoices', 'file-text', 'invoices', 'site.manage'],
['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage'],
// What customers wrote to us, and what we sent them. Under
// Betrieb rather than System: both are read while answering
// somebody, not while configuring a machine.
// Eigener Eintrag, kein Abschnitt der Einstellungen: was hier
// gesetzt wird, steht auf einem Rechtsdokument, und neben dem
// Sichtbarkeitsschalter ändert das jemand im Vorbeigehen.
['admin.finance', 'receipt', 'finance', 'site.manage'],
]],
// Was geschrieben wird — gelesen beim Antworten, nicht beim
// Einrichten.
['label' => __('admin.nav_group.post'), 'items' => [
['admin.inbox', 'mail', 'inbox', 'customers.manage'],
['admin.mail-log', 'send', 'mail_log', 'customers.manage'],
// The answers, written once. Beside the two mail pages because
// that is where they are used, not under System — nothing about
// a template configures the installation.
['admin.templates', 'file-text', 'templates', 'customers.manage'],
]],
['label' => __('admin.nav_group.system'), 'items' => [
['admin.mail', 'mail', 'mail', 'mail.manage'],
// Looking at a mail before a customer does. Beside the mailboxes
// it is sent from, not under Betrieb: both are about how mail
// leaves this installation.
// Stand vorher unter System, neben den Postfächern. Sie zeigt
// aber VORLAGEN, keine Postfächer — und gehört deshalb neben
// die Vorlagen, die sie darstellt.
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
// The processing agreement. Under System with the other things
// that are true of the whole installation rather than of one
// customer.
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
// System, not Betrieb: the tunnel is how the console reaches
// the estate at all. Nothing is provisioned, monitored or
// updated through anything else, so it belongs beside the other
// things that have to work before any of the day's work can.
['admin.vpn', 'shield', 'vpn', null],
]],
// Was einmal eingerichtet wird und danach gilt.
['label' => __('admin.nav_group.system'), 'items' => [
// Die Postfächer und der Server, über den Mail dieses Haus
// verlässt. Einrichtung, anders als die vier Seiten unter Post.
['admin.mail', 'mail', 'mail', 'mail.manage'],
// Merged from the former admin.secrets + admin.infrastructure
// pages, which split the same subject by storage mechanism
// instead of by what it configures. Reachable with EITHER
@ -106,6 +117,9 @@ final class Navigation
// one page — same gate as Integrations just above, because
// both halves it configures come back together here.
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
// Der AV-Vertrag: gilt für die ganze Installation, nicht für
// einen Kunden.
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
// Two-factor enrolment used to sit here as an entry of its
// own. It is a tab of Settings now — it is a setting, and it
// was the one place an operator had to leave the settings page

View File

@ -6,7 +6,11 @@ return [
'to_portal' => 'Zum Kundenportal',
'nav_group' => [
'customers' => 'Kunden',
'fleet' => 'Flotte',
'operations' => 'Betrieb',
'billing' => 'Abrechnung',
'post' => 'Post',
'system' => 'System',
],

View File

@ -6,7 +6,11 @@ return [
'to_portal' => 'To customer portal',
'nav_group' => [
'customers' => 'Customers',
'fleet' => 'Fleet',
'operations' => 'Operations',
'billing' => 'Billing',
'post' => 'Mail',
'system' => 'System',
],

View File

@ -41,22 +41,43 @@
aside past the viewport same mechanism <main> already relies on. --}}
<div class="min-h-0 flex-1 overflow-y-auto">
@foreach ($groups as $group)
@php
// Sichtbarkeit EINMAL je Gruppe entscheiden, nicht erst je
// Eintrag. Vorher wurde die Überschrift bedingungslos gedruckt
// und stand deshalb auch über einer Gruppe, von der dieser
// Betreiber keinen einzigen Eintrag sehen darf — eine
// Überschrift mit nichts darunter liest sich wie ein
// Ladefehler, nicht wie eine Berechtigung.
//
// Bis zur Neuordnung der Konsolen-Navigation fiel das nicht
// auf, weil jede Gruppe mindestens einen ungeschützten Eintrag
// trug. „Post" tut das nicht.
//
// $capability ist ein einzelner Fähigkeitsname, ein Array von
// Namen im Sinne von „irgendeine davon" (App\Support\Navigation
// hält fest, welche Einträge das benutzen), oder null für
// „alle". Laravels eigenes can() liest ein Array als „alle
// davon" — die falsche Logik für einen Eintrag, den man mit
// der einen ODER der anderen Fähigkeit erreicht. Daher die
// ausgeschriebene Verzweigung statt eines bloßen ->can().
$visible = collect($group['items'])->filter(function (array $item) {
$capability = $item[3];
if ($capability === null) {
return true;
}
return is_array($capability)
? collect($capability)->contains(fn ($one) => auth()->user()?->can($one))
: (bool) auth()->user()?->can($capability);
});
@endphp
@continue($visible->isEmpty())
@if (! empty($group['label']))
<p class="px-3 pb-2 pt-5 lbl">{{ $group['label'] }}</p>
@endif
<nav class="space-y-0.5">
@foreach ($group['items'] as [$route, $icon, $key, $capability])
{{-- $capability is a single ability name, an array of
ability names meaning "any of these" (App\Support\
Navigation documents which entries use that), or
null for "everyone". Laravel's own can() treats an
array as "all of these", which is the wrong logic
for an entry reachable with either of two
capabilities hence the explicit branch instead of
a bare ->can($capability) for both shapes. --}}
@continue($capability !== null && ! (is_array($capability)
? collect($capability)->contains(fn ($one) => auth()->user()?->can($one))
: auth()->user()?->can($capability)))
@foreach ($visible as [$route, $icon, $key, $capability])
<x-ui.nav-item
:href="route($route)"
:active="$console ? \App\Support\AdminArea::routeIs($route) : request()->routeIs($route)"

View File

@ -0,0 +1,125 @@
<?php
use App\Models\Operator;
use App\Support\Navigation;
use Illuminate\Support\Facades\Route;
/**
* Die Seitenleiste der Konsole.
*
* Sie trug 26 Einträge in drei Gruppen, von denen eine „Betrieb" — allein
* zwölf hielt: Maschinen, Geld und Post unter einem Wort, das auf alle drei
* zutrifft und deshalb keinem hilft. Neu geordnet in sechs Gruppen.
*
* Was diese Tests festhalten, ist nicht die Ordnung selbst (die darf sich
* ändern), sondern dass beim Umsortieren nichts verlorengeht und keine leere
* Überschrift entsteht.
*/
/** @return list<array{0:string,1:string,2:string,3:mixed}> */
function consoleItems(): array
{
return collect(Navigation::console())->flatMap(fn (array $g) => $g['items'])->all();
}
/**
* Die Gruppen-Überschrift, wie x-shell.nav sie zeichnet.
*
* Auf das Element geprüft und nicht auf das blanke Wort: „Post" steckt auch in
* „Posteingang". Ein Teilstring-Vergleich auf ein kurzes Wort besteht aus
* Gründen, die mit der Prüfung nichts zu tun haben.
*/
function navLabel(string $label): string
{
return 'lbl">'.e($label).'</p>';
}
it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', function () {
$routes = array_column(consoleItems(), 0);
// Doppelte wären beim Verschieben von Hand die naheliegende Panne: einmal
// an der neuen Stelle eingefügt, an der alten vergessen.
expect($routes)->toHaveCount(count(array_unique($routes)));
// Die Zahl steht hier bewusst als Zahl: sinkt sie, ist beim Umsortieren
// ein Eintrag unter den Tisch gefallen, und genau das sähe niemand.
expect($routes)->toHaveCount(26);
});
it('führt keinen Eintrag, dessen Route es nicht gibt', function () {
foreach (consoleItems() as [$route, , $key]) {
expect(Route::has($route))->toBeTrue("Route {$route} (Eintrag {$key}) existiert nicht.");
}
});
it('gibt jedem Eintrag eine übersetzte Beschriftung in beiden Sprachen', function () {
foreach (['de', 'en'] as $locale) {
app()->setLocale($locale);
foreach (consoleItems() as [, , $key]) {
expect(__('admin.nav.'.$key))->not->toBe('admin.nav.'.$key, "{$key} fehlt in lang/{$locale}/admin.php");
}
foreach (Navigation::console() as $group) {
if ($group['label'] !== null) {
expect($group['label'])->not->toStartWith('admin.nav_group.');
}
}
}
});
it('stellt jede Gruppe unter eine Überschrift — außer der Übersicht', function () {
$groups = Navigation::console();
// Die erste Gruppe trägt allein die Übersicht und braucht keine
// Überschrift: sie ist die Seite, auf der man landet.
expect($groups[0]['label'])->toBeNull()
->and($groups[0]['items'])->toHaveCount(1)
->and($groups[0]['items'][0][0])->toBe('admin.overview');
// Alle weiteren haben eine. Eine namenlose Gruppe mitten in der Leiste
// wäre wieder der Zustand, aus dem diese Arbeit kommt: sechs schwebende
// Einträge ohne Zugehörigkeit.
foreach (array_slice($groups, 1) as $group) {
expect($group['label'])->not->toBeNull()->and($group['label'])->not->toBe('');
}
});
it('lässt keine Gruppe so groß werden, dass sie wieder alles verschluckt', function () {
// „Betrieb" hielt zwölf. Die Grenze ist kein Selbstzweck: ab etwa sieben
// liest man eine Gruppe nicht mehr auf einen Blick, und dann fängt das
// Sortieren von vorne an.
foreach (Navigation::console() as $group) {
expect(count($group['items']))->toBeLessThanOrEqual(6, "Gruppe „{$group['label']}\" ist auf ".count($group['items']).' Einträge gewachsen.');
}
});
it('zeigt einem Betreiber ohne Berechtigungen keine leere Überschrift', function () {
// „Post" besteht ausschließlich aus geschützten Einträgen. Vor dieser
// Arbeit druckte x-shell.nav die Überschrift bedingungslos — die Gruppe
// hätte also als Wort ohne Inhalt dagestanden.
//
// „Read-only" trägt weder customers.manage noch mail.manage, sieht also
// keinen einzigen Eintrag dieser Gruppe.
$this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator');
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
// Auf das Überschriften-Element geprüft, nicht auf das blanke Wort:
// „Post" steckt auch in „Posteingang" und „Postausgang", und ein
// Teilstring-Vergleich hätte hier etwas anderes geprüft als behauptet.
expect($html)->not->toContain(navLabel(__('admin.nav_group.post')));
});
it('zeigt die Gruppe, sobald der Betreiber einen ihrer Einträge sehen darf', function () {
// Die Kehrseite: die Überschrift darf nicht aus Vorsicht verschwinden.
$this->actingAs(admin(), 'operator');
$html = $this->get(route('admin.overview'))->assertOk()->getContent();
foreach (Navigation::console() as $group) {
if ($group['label'] !== null) {
expect($html)->toContain(navLabel($group['label']));
}
}
});

View File

@ -75,19 +75,29 @@ it('lists every tab it can render, and renders every tab it lists', function ()
}
});
it('moves the tunnel to System and drops the two-factor entry from the menu', function () {
// VPN is not a task of the day's operations — it is how the console reaches
// the estate at all, so it belongs beside the other things that have to
// work first. Two-factor left the menu entirely: it is a tab now.
it('keeps the tunnel with the fleet and drops the two-factor entry from the menu', function () {
// The tunnel sat under System, on the argument that it is how the console
// reaches the estate at all and therefore belongs beside the things that
// have to work before any of the day's work can.
//
// That argument holds — and it holds just as well for Integrations and
// Readiness, which stayed under System. What decided it instead: this page
// is a LIST OF PEERS an operator reads while working, the same way they
// read the host list. Reading a list of machines is not configuring an
// installation. Same reasoning that already pulled Hostnamen out of System.
//
// Two-factor left the menu entirely: it is a tab now.
$groups = collect(Navigation::console());
$routesIn = fn (string $label) => $groups
->firstWhere('label', __($label))['items'] ?? [];
$fleet = collect($routesIn('admin.nav_group.fleet'))->pluck(0);
$system = collect($routesIn('admin.nav_group.system'))->pluck(0);
$operations = collect($routesIn('admin.nav_group.operations'))->pluck(0);
expect($system)->toContain('admin.vpn')
expect($fleet)->toContain('admin.vpn')
->and($system)->not->toContain('admin.vpn')
->and($operations)->not->toContain('admin.vpn')
->and($groups->pluck('items')->flatten(1)->pluck(0))
->not->toContain('admin.two-factor-setup');