289 lines
13 KiB
PHP
289 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\DunningCase;
|
|
use App\Models\FailedCheckout;
|
|
use App\Models\Incident;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* The navigation of both shells, in one place.
|
|
*
|
|
* The sidebar and the breadcrumb describe the same structure, and when each
|
|
* built its own copy the breadcrumb quietly said "Übersicht" on every page —
|
|
* the layout had no way to know which entry was current, so it fell back to the
|
|
* first one. One definition, two readers.
|
|
*
|
|
* Each entry is [route name, icon, translation key, capability or null].
|
|
*/
|
|
final class Navigation
|
|
{
|
|
/** @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:?string}>}> */
|
|
public static function portal(): array
|
|
{
|
|
return [
|
|
['label' => null, 'items' => [
|
|
['dashboard', 'gauge', 'overview', null],
|
|
['cloud', 'cloud', 'cloud', null],
|
|
// The only portal entry with a condition. Start carries no
|
|
// custom domain and cannot buy one, so the tab would lead
|
|
// straight to the 403 its page raises.
|
|
['domain', 'globe', 'domain', 'use-custom-domain'],
|
|
['users', 'users', 'users', null],
|
|
['backups', 'database', 'backups', null],
|
|
// Route heisst 'portal.security' statt bloss 'security' — der
|
|
// Pfad '/security' gehoert schon der oeffentlichen
|
|
// Aufklaerungsseite (routes/web.php), siehe deren Kommentar.
|
|
['portal.security', 'shield', 'security', null],
|
|
]],
|
|
['label' => __('dashboard.nav_group.contract'), 'items' => [
|
|
['billing', 'box', 'billing', null],
|
|
['invoices', 'receipt', 'invoices', null],
|
|
['settings', 'settings', 'settings', null],
|
|
['support', 'life-buoy', 'support', null],
|
|
]],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Die Konsole, geordnet nach dem, was man mit ihr tut.
|
|
*
|
|
* Es waren achtundzwanzig Eintraege in sieben Gruppen, und „System" war der
|
|
* Platz fuer alles, was sonst nirgends hinpasste: Mail-Einrichtung neben
|
|
* einem Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben
|
|
* der Mitarbeiterverwaltung — und ganz unten die Seite, die sagt, was
|
|
* liegt. Der Betreiber hat es so beschrieben: „offene Punkte ist der letzte
|
|
* Punkt, dann Rolle drueber und Einstellungen wieder drueber".
|
|
*
|
|
* Zwei Regeln ordnen es jetzt. Was man einmal einrichtet, verlaesst die
|
|
* Leiste in den Einrichtungsbereich (admin.setup). Und die drei Seiten, auf
|
|
* denen etwas auf jemanden WARTET, stehen ganz oben, mit einer Zahl daneben
|
|
* — das ist die Frage, mit der man eine Konsole oeffnet.
|
|
*
|
|
* Jede Seite steht genau EINMAL. Stoerungen sind aus „Betrieb" nach oben
|
|
* gezogen und Zahlungsprobleme aus „Geld"; sie sind dort nicht zusaetzlich
|
|
* geblieben. Eine Seite an zwei Stellen ist ihre eigene Verwirrung.
|
|
*
|
|
* Ein Eintrag ist [Route, Symbol, Uebersetzungsschluessel, Berechtigung],
|
|
* optional gefolgt vom Schluessel, unter dem attentionCounts() seine Zahl
|
|
* fuehrt.
|
|
*
|
|
* @return array<int, array{label: ?string, items: array<int, array{0:string,1:string,2:string,3:mixed,4?:string}>}>
|
|
*/
|
|
public static function console(): array
|
|
{
|
|
return [
|
|
// Allein und ohne Ueberschrift: die Seite, auf der man landet.
|
|
['label' => null, 'items' => [
|
|
['admin.overview', 'gauge', 'overview', null],
|
|
]],
|
|
// Wo etwas auf jemanden wartet. Der einzige Block, der eine Zahl
|
|
// traegt — und der einzige, der nach Dringlichkeit sortiert ist
|
|
// statt nach Gegenstand.
|
|
['label' => __('admin.nav_group.attention'), 'items' => [
|
|
// Ohne Berechtigung: wer die Konsole oeffnen darf, soll wissen,
|
|
// worauf er sich verlassen kann und worauf nicht.
|
|
['admin.open-work', 'alert-triangle', 'open_work', null, 'open_work'],
|
|
['admin.incidents', 'bell', 'incidents', null, 'incidents'],
|
|
['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage', 'payment_problems'],
|
|
]],
|
|
// Wer bei uns ist und was er hat.
|
|
['label' => __('admin.nav_group.customers'), 'items' => [
|
|
['admin.customers', 'users', 'customers', null],
|
|
['admin.instances', 'box', 'instances', null],
|
|
]],
|
|
// 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'],
|
|
// Eine LISTE VON GEGENSTELLEN, die man beim Arbeiten ansieht,
|
|
// so wie die Hostliste — nicht etwas, das man einrichtet.
|
|
['admin.vpn', 'shield', 'vpn', null],
|
|
]],
|
|
// Was gerade laeuft. Vorgaenge, kein Bestand. Stoerungen standen
|
|
// hier und stehen jetzt oben: sie sind kein Vorgang, den man
|
|
// verfolgt, sondern einer, der jemanden braucht.
|
|
['label' => __('admin.nav_group.operations'), 'items' => [
|
|
['admin.provisioning', 'activity', 'provisioning', null],
|
|
['admin.maintenance', 'alert-triangle', 'maintenance', null],
|
|
// Laeuft, statt eingerichtet zu werden — deshalb hier und nicht
|
|
// im Einrichtungsbereich neben admin.mail.
|
|
['admin.mail-pace', 'gauge', 'mail_pace', 'mail.manage'],
|
|
]],
|
|
// Alles, wo Geld drinsteht.
|
|
['label' => __('admin.nav_group.billing'), 'items' => [
|
|
['admin.revenue', 'trending-up', 'revenue', null],
|
|
['admin.invoices', 'file-text', 'invoices', 'site.manage'],
|
|
// Eigener Eintrag, kein Abschnitt der Einstellungen: was hier
|
|
// gesetzt wird, steht auf einem Rechtsdokument, und neben dem
|
|
// Sichtbarkeitsschalter aendert das jemand im Vorbeigehen.
|
|
['admin.finance', 'receipt', 'finance', 'site.manage'],
|
|
]],
|
|
// Was geschrieben wird — gelesen beim Antworten. Die Vorlagen und
|
|
// ihre Vorschau sind dagegen etwas, das man einmal schreibt: sie
|
|
// stehen im Einrichtungsbereich.
|
|
['label' => __('admin.nav_group.post'), 'items' => [
|
|
['admin.inbox', 'mail', 'inbox', 'customers.manage'],
|
|
['admin.mail-log', 'send', 'mail_log', 'customers.manage'],
|
|
]],
|
|
// Ohne Ueberschrift und ganz unten: eine Tuer, keine Gruppe.
|
|
['label' => null, 'items' => [
|
|
['admin.setup', 'settings', 'setup', null],
|
|
]],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Die Zahlen fuer den Block „Braucht dich".
|
|
*
|
|
* Diese Leiste rendert auf JEDER Konsolenseite. Ohne Zwischenspeicher
|
|
* waeren das zwei Abfragen je Seitenaufruf — eine Abgabe, die man spaeter
|
|
* sucht, wenn die Seiten langsam werden. Eine Minute ist kurz genug, dass
|
|
* niemand eine geloeste Stoerung noch lange gezaehlt sieht, und lang genug,
|
|
* dass ein Klick durch fuenf Seiten die Datenbank einmal fragt statt zehnmal.
|
|
*
|
|
* „Offene Punkte" kostet ohnehin nichts: das Register liegt im Code.
|
|
*
|
|
* @return array<string, int>
|
|
*/
|
|
public static function attentionCounts(): array
|
|
{
|
|
return Cache::remember('nav.attention', now()->addMinute(), fn () => [
|
|
'open_work' => count(OpenWork::all()),
|
|
'incidents' => Incident::query()->whereNull('resolved_at')->count(),
|
|
// Beides, weil die Seite beides zeigt: eine offene Mahnung und ein
|
|
// gescheiterter Bezahlvorgang sind zwei Wege zu demselben Problem.
|
|
'payment_problems' => DunningCase::query()->whereNull('settled_at')->count()
|
|
+ FailedCheckout::query()->whereNull('resolved_at')->count(),
|
|
// Die Aktualisierung, obwohl sie in keinem Eintrag der Leiste
|
|
// steht: sie steht im FUSS, neben der laufenden Version.
|
|
//
|
|
// Der Betreiber hat es so gemeldet: „Einstellungen ist in der
|
|
// Einrichtung, wegen den Versionsupdates — man sieht es nicht,
|
|
// ohne genau hinzuklicken." Die Seite dorthin zu verschieben war
|
|
// richtig (ihr Konto-Teil gehoert dahin), die Folge nicht: eine
|
|
// verfuegbare Aktualisierung ist nichts, wonach man sucht.
|
|
//
|
|
// Hier statt in einem eigenen Eintrag, weil die Version im Fuss
|
|
// ohnehin schon steht. Ein vierter Eintrag unter „Braucht dich"
|
|
// haette dieselbe Auskunft an einer zweiten Stelle wiederholt.
|
|
'update' => app(UpdateChannel::class)->state()['available'] ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Der Einrichtungsbereich: die neun Seiten, die man einmal einrichtet.
|
|
*
|
|
* Sie stehen hier und nicht im Livewire-Bauteil, das sie zeichnet — aus
|
|
* demselben Grund, aus dem die Seitenleiste hier steht: es ist Navigation,
|
|
* und wer sie liest, sind drei (die Kachelseite, die Markierung „du bist
|
|
* hier" und die Brotkrume). Drei Leser, eine Liste.
|
|
*
|
|
* Dieselbe Form wie ein Eintrag der Leiste, und dieselbe Regel: ein Array
|
|
* bei der Berechtigung heisst „eine davon genuegt", nie „alle".
|
|
*
|
|
* @return array<string, array<int, array{0:string,1:string,2:string,3:mixed}>>
|
|
*/
|
|
public static function setup(): array
|
|
{
|
|
return [
|
|
// Womit und woraus dieses Haus schreibt.
|
|
'delivery' => [
|
|
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
|
['admin.templates', 'file-text', 'templates', 'customers.manage'],
|
|
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
|
|
],
|
|
// Was verkauft wird.
|
|
'offer' => [
|
|
['admin.plans', 'tag', 'plans', 'plans.manage'],
|
|
],
|
|
// Wer hereindarf und womit dieses Haus nach aussen spricht.
|
|
'access' => [
|
|
['admin.roles', 'users', 'roles', 'staff.manage'],
|
|
['admin.settings', 'settings', 'settings', null],
|
|
['admin.integrations', 'plug', 'integrations', ['hosts.manage', 'secrets.manage']],
|
|
],
|
|
// Der Zustand des Hauses selbst.
|
|
'house' => [
|
|
['admin.readiness', 'shield-check', 'readiness', ['hosts.manage', 'secrets.manage']],
|
|
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ist dieser Eintrag der, auf dem man gerade steht?
|
|
*
|
|
* Fuer alles ausser dem Einrichtungsbereich ist das die Route selbst. Der
|
|
* Einrichtungsbereich bleibt zusaetzlich markiert, solange man auf einer
|
|
* der Seiten steht, die er sammelt — sonst verliert die Leiste auf allen
|
|
* neun verschobenen Seiten ihr „du bist hier", und man steht in einer
|
|
* Konsole, die nicht mehr sagt, wo man ist. (Codex R15, P2 am Umbau.)
|
|
*/
|
|
public static function isCurrent(string $route, bool $console = false): bool
|
|
{
|
|
if ($console ? AdminArea::routeIs($route) : request()->routeIs($route)) {
|
|
return true;
|
|
}
|
|
|
|
if ($console && $route === 'admin.setup') {
|
|
foreach (self::setup() as $kacheln) {
|
|
foreach ($kacheln as [$ziel]) {
|
|
if (AdminArea::routeIs($ziel)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The label of the entry the current request belongs to.
|
|
*
|
|
* Matched on the route rather than the path, because the console's path
|
|
* changes between host-bound and fallback mode while its route names do
|
|
* not. Null when the page is not in the navigation at all — a detail view,
|
|
* a modal — and the caller then supplies its own title.
|
|
*/
|
|
public static function currentLabel(bool $console = false): ?string
|
|
{
|
|
$prefix = $console ? 'admin.nav.' : 'dashboard.nav.';
|
|
|
|
foreach ($console ? self::console() : self::portal() as $group) {
|
|
foreach ($group['items'] as [$route, , $key]) {
|
|
$matches = $console
|
|
? AdminArea::routeIs($route)
|
|
: request()->routeIs($route);
|
|
|
|
if ($matches) {
|
|
return __($prefix.$key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Die neun verschobenen Seiten stehen nicht mehr in der Leiste. Ohne
|
|
// diesen zweiten Blick verloeren sie ihre Brotkrume und die Konsole
|
|
// sagte auf ihnen nur noch „Konsole" — derselbe Schaden wie die
|
|
// fehlende Markierung, aus derselben Ursache.
|
|
if ($console) {
|
|
foreach (self::setup() as $kacheln) {
|
|
foreach ($kacheln as [$route, , $key]) {
|
|
if (AdminArea::routeIs($route)) {
|
|
return __($prefix.$key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|