From 1886bf207624da5597a8c08d59db94146af3cb07 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 17:44:29 +0200 Subject: [PATCH] Sicht auf die Spuren, und ein Schalter, der die Drossel abstellt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn die Drossel je klemmt, muss der Ausweg ein Klick sein und kein Deployment. Eigene Seite (admin/mail-pace) statt eines fünften Abschnitts auf admin/mail: die dortige Seite ist bereits "alles in einer Wurst", und diese hier beobachtet laufenden Betrieb statt etwas einzurichten — deshalb auch in der Navigation unter "Betrieb", nicht unter "System". Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/MailPace.php | 168 ++++++++++++++++++ app/Support/Navigation.php | 3 + lang/de/admin.php | 1 + lang/de/mail_pace.php | 38 ++++ lang/en/admin.php | 1 + lang/en/mail_pace.php | 37 ++++ .../views/livewire/admin/mail-pace.blade.php | 93 ++++++++++ routes/admin.php | 4 + tests/Feature/Admin/ConsoleNavigationTest.php | 3 +- tests/Feature/Admin/MailPacePageTest.php | 107 +++++++++++ 10 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 app/Livewire/Admin/MailPace.php create mode 100644 lang/de/mail_pace.php create mode 100644 lang/en/mail_pace.php create mode 100644 resources/views/livewire/admin/mail-pace.blade.php create mode 100644 tests/Feature/Admin/MailPacePageTest.php diff --git a/app/Livewire/Admin/MailPace.php b/app/Livewire/Admin/MailPace.php new file mode 100644 index 0000000..9e7db3d --- /dev/null +++ b/app/Livewire/Admin/MailPace.php @@ -0,0 +1,168 @@ +authorize('mail.manage'); + + $this->enabled = Settings::bool('mail.pace.enabled', true); + $this->urgentCount = (int) Settings::get('mail.pace.urgent.count', 30); + $this->urgentMinutes = (int) Settings::get('mail.pace.urgent.minutes', 5); + $this->calmCount = (int) Settings::get('mail.pace.calm.count', 20); + $this->calmMinutes = (int) Settings::get('mail.pace.calm.minutes', 10); + } + + /** + * Der Notschalter: nach dem Muster von Admin\Plans::toggleSales() — eine + * Methode, eine Rückmeldung, kein Bestätigungsmodal. Er ist umkehrbar, + * derselbe Klick legt ihn zurück. + */ + public function togglePace(): void + { + $this->authorize('mail.manage'); + + $this->enabled = ! $this->enabled; + Settings::set('mail.pace.enabled', $this->enabled); + + $this->dispatch('notify', message: __($this->enabled ? 'mail_pace.enabled_on_notice' : 'mail_pace.enabled_off_notice')); + } + + /** + * Die beiden Kontingente speichern. + * + * `min:1` auf allen vier Feldern ist die zweite Hälfte der Untergrenze aus + * MailPaceServiceProvider::mindestensEins() — die liest nur ab, was hier + * gar nicht erst gespeichert werden darf. Ohne diese Prüfung käme eine + * eingetippte 0 ungeprüft bis in die Einstellung durch und flöge erst beim + * nächsten Auftrag an der Lesestelle auf, nachdem der Betreiber schon + * „gespeichert" gesehen hat — ein Kontingent von 0 heißt dort: jede Mail + * wird endlos zurückgelegt, ein Fenster von 0 Minuten schaltet die Drossel + * still ab. + */ + public function savePace(): void + { + $this->authorize('mail.manage'); + + $data = $this->validate([ + 'urgentCount' => ['required', 'integer', 'min:1'], + 'urgentMinutes' => ['required', 'integer', 'min:1'], + 'calmCount' => ['required', 'integer', 'min:1'], + 'calmMinutes' => ['required', 'integer', 'min:1'], + ]); + + Settings::set('mail.pace.urgent.count', $data['urgentCount']); + Settings::set('mail.pace.urgent.minutes', $data['urgentMinutes']); + Settings::set('mail.pace.calm.count', $data['calmCount']); + Settings::set('mail.pace.calm.minutes', $data['calmMinutes']); + + $this->dispatch('notify', message: __('mail_pace.saved')); + } + + /** + * Eine Mailklasse in eine andere Spur verschieben. + * + * Ein Ziel außerhalb der drei bekannten Spuren kommt nur über einen + * manuellen Aufruf zustande — kein bietet einer gesperrten Klasse eines an, + // also nur ein manueller Aufruf — bekommt eine Meldung statt eines + // Serverfehlers. + $this->dispatch('notify', message: __('mail_pace.locked_notice')); + + return; + } + + $this->dispatch('notify', message: __('mail_pace.moved')); + } + + public function render() + { + // Je Spur: die Warteschlange, die App\Mail\Concerns\RidesALane + // tatsächlich befüllt (Task 2) — derselbe Name, den auch der Arbeiter + // in docker-compose.yml abhört (Task 4). Im echten Betrieb (Redis) + // eine ehrliche Zahl aus der tatsächlichen Liste; unter der + // Sync-Warteschlange, die die Testsuite erzwingt, immer 0, weil dort + // nichts je wartet statt verschickt zu werden — dort ist die Zahl + // ebenso ehrlich, nur bedeutungslos für einen Testfall, der einen + // Rückstand nachstellen wollte. + $lanes = [ + [ + 'key' => MailLane::DIRECT, + 'throttled' => false, + 'waiting' => Queue::size(MailLane::DIRECT), + ], + [ + 'key' => MailLane::URGENT, + 'throttled' => true, + 'waiting' => Queue::size(MailLane::URGENT), + 'countField' => 'urgentCount', + 'minutesField' => 'urgentMinutes', + ], + [ + 'key' => MailLane::CALM, + 'throttled' => true, + 'waiting' => Queue::size(MailLane::CALM), + 'countField' => 'calmCount', + 'minutesField' => 'calmMinutes', + ], + ]; + + return view('livewire.admin.mail-pace', [ + 'lanes' => $lanes, + 'laneOptions' => [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM], + 'assignments' => MailLane::all(), + ]); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 9bae82a..56a73c7 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -81,6 +81,9 @@ final class Navigation ['admin.provisioning', 'activity', 'provisioning', null], ['admin.maintenance', 'alert-triangle', 'maintenance', null], ['admin.incidents', 'bell', 'incidents', null], + // Läuft, statt eingerichtet zu werden — deshalb hier und nicht + // neben admin.mail unter System. + ['admin.mail-pace', 'gauge', 'mail_pace', 'mail.manage'], ]], // Alles, wo Geld drinsteht. ['label' => __('admin.nav_group.billing'), 'items' => [ diff --git a/lang/de/admin.php b/lang/de/admin.php index 0769c34..ef6d14e 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -37,6 +37,7 @@ return [ 'payment_problems' => 'Zahlungsprobleme', 'revenue' => 'Umsatz', 'mail' => 'E-Mail', + 'mail_pace' => 'Versandtakt', 'integrations' => 'Integrationen', 'readiness' => 'Bereitschaft', 'settings' => 'Einstellungen', diff --git a/lang/de/mail_pace.php b/lang/de/mail_pace.php new file mode 100644 index 0000000..1388034 --- /dev/null +++ b/lang/de/mail_pace.php @@ -0,0 +1,38 @@ + 'Betrieb', + 'title' => 'Versandtakt', + 'subtitle' => 'Wie schnell die drei Spuren fahren, was gerade wartet, und der Schalter, der die Drossel im Ernstfall sofort abstellt.', + + 'enabled' => 'Drossel', + 'enabled_hint' => 'Ein Umlegen wirkt erst für neu eingereihte Mails voll: bereits wartende Aufträge behalten das Zeitfenster oder die Drossel, mit der sie eingereiht wurden.', + 'enabled_on' => 'Aktiv', + 'enabled_off' => 'Aus', + 'enabled_on_notice' => 'Drossel wieder eingeschaltet.', + 'enabled_off_notice' => 'Drossel abgeschaltet — jede Mail fährt jetzt ungebremst.', + + 'lanes_title' => 'Die drei Spuren', + 'lanes_sub' => 'Direkt fährt immer ungedrosselt — dort wartet gerade ein Mensch. Wichtig und ruhig haben ein eigenes Kontingent; beide Zahlen müssen mindestens eins sein, sonst wird entweder jede Mail endlos zurückgelegt oder gar nicht mehr gedrosselt.', + 'lane' => [ + 'mail-direkt' => 'Direkt', + 'mail-wichtig' => 'Wichtig', + 'mail-ruhig' => 'Ruhig', + ], + 'unthrottled' => 'ungedrosselt', + 'count' => 'Kontingent', + 'minutes' => 'Minuten', + 'waiting' => ':count wartend', + 'save' => 'Speichern', + 'saved' => 'Takt gespeichert.', + + 'assignments_title' => 'Zuordnung', + 'assignments_sub' => 'Welche Mailklasse in welcher Spur fährt. Gesperrte Klassen fahren immer direkt — darauf wartet gerade jemand, drosseln würde dort nur schaden.', + 'locked_hint' => 'Immer direkt (gesperrt)', + 'locked_notice' => 'Diese Mail fährt immer direkt: darauf wartet gerade jemand.', + 'moved' => 'Verschoben.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index aca0ec7..d862065 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -37,6 +37,7 @@ return [ 'payment_problems' => 'Payment problems', 'revenue' => 'Revenue', 'mail' => 'Email', + 'mail_pace' => 'Send pace', 'integrations' => 'Integrations', 'readiness' => 'Readiness', 'settings' => 'Settings', diff --git a/lang/en/mail_pace.php b/lang/en/mail_pace.php new file mode 100644 index 0000000..3d70756 --- /dev/null +++ b/lang/en/mail_pace.php @@ -0,0 +1,37 @@ + 'Operations', + 'title' => 'Send pace', + 'subtitle' => 'How fast the three lanes move, what is waiting right now, and the switch that stops the throttle immediately if it jams.', + + 'enabled' => 'Throttle', + 'enabled_hint' => 'Flipping this only fully applies to mail queued from now on: jobs already waiting keep whichever time window or throttle they were queued with.', + 'enabled_on' => 'On', + 'enabled_off' => 'Off', + 'enabled_on_notice' => 'Throttle switched back on.', + 'enabled_off_notice' => 'Throttle switched off — every mail now sends unthrottled.', + + 'lanes_title' => 'The three lanes', + 'lanes_sub' => 'Direct always runs unthrottled — a human is waiting there right now. Urgent and calm each have their own allowance; both numbers must be at least one, or mail is either delayed forever or not throttled at all.', + 'lane' => [ + 'mail-direkt' => 'Direct', + 'mail-wichtig' => 'Urgent', + 'mail-ruhig' => 'Calm', + ], + 'unthrottled' => 'unthrottled', + 'count' => 'Allowance', + 'minutes' => 'Minutes', + 'waiting' => ':count waiting', + 'save' => 'Save', + 'saved' => 'Pace saved.', + + 'assignments_title' => 'Assignment', + 'assignments_sub' => 'Which mail class rides in which lane. Locked classes always ride direct — someone is waiting on them right now, and throttling would only hurt.', + 'locked_hint' => 'Always direct (locked)', + 'locked_notice' => 'This mail always rides direct: someone is waiting on it right now.', + 'moved' => 'Moved.', +]; diff --git a/resources/views/livewire/admin/mail-pace.blade.php b/resources/views/livewire/admin/mail-pace.blade.php new file mode 100644 index 0000000..17b208e --- /dev/null +++ b/resources/views/livewire/admin/mail-pace.blade.php @@ -0,0 +1,93 @@ +
+
+

{{ __('mail_pace.eyebrow') }}

+

+ {{ __('mail_pace.title') }} +

+

{{ __('mail_pace.subtitle') }}

+
+ + {{-- Der Notschalter, ganz oben und in einem eigenen Panel: das ist der + Griff, den jemand im Ernstfall zuerst sucht, nicht der letzte von + mehreren. Kein Bestätigungsmodal (R23 verlangt eines nur dort, wo ein + Vorgang Folgen hat, die man nicht zurückdrehen kann) — dieser Schalter + ist umkehrbar, derselbe Klick legt ihn zurück, genau wie + Admin\Plans::toggleSales(). --}} + +
+ +
+
+ + {{-- Die drei Spuren: Name, Takt, was gerade wartet. --}} +
+

{{ __('mail_pace.lanes_title') }}

+

{{ __('mail_pace.lanes_sub') }}

+ + + @foreach ($lanes as $lane) + +
+ {{ $lane['key'] }} + + @if ($lane['throttled']) +
+ +
+
+ +
+ @else + {{ __('mail_pace.unthrottled') }} + @endif + + {{ __('mail_pace.waiting', ['count' => $lane['waiting']]) }} +
+ {{-- Kein eigenes @error hier: x-ui.input zeigt die Meldung + zum jeweiligen Feld schon selbst unter sich an. --}} +
+ @endforeach +
+ +
+ {{ __('mail_pace.save') }} +
+
+ + {{-- Die Zuordnung: welche Mailklasse in welcher Spur fährt. Ein + @foreach ($laneOptions as $option) + + @endforeach + + @endif + + @endforeach + + +
diff --git a/routes/admin.php b/routes/admin.php index c8cbb56..bc7ee96 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -123,6 +123,10 @@ Route::get('/invoices/{uuid}/pdf', function (string $uuid) { ); })->name('invoices.pdf'); Route::get('/mail', Admin\Mail::class)->name('mail'); +// Der Notschalter und die Sicht auf die drei Versandspuren — eigene Route statt +// eines weiteren Abschnitts auf admin/mail (siehe deren Kommentar oben), weil +// diese Seite den laufenden Betrieb beobachtet statt etwas einzurichten. +Route::get('/mail-pace', Admin\MailPace::class)->name('mail-pace'); Route::get('/integrations', Admin\Integrations::class)->name('integrations'); // The former admin.secrets and admin.infrastructure pages, merged into the // one above — grouped by what each value configures, not by which of the two diff --git a/tests/Feature/Admin/ConsoleNavigationTest.php b/tests/Feature/Admin/ConsoleNavigationTest.php index c59794a..121d97e 100644 --- a/tests/Feature/Admin/ConsoleNavigationTest.php +++ b/tests/Feature/Admin/ConsoleNavigationTest.php @@ -43,7 +43,8 @@ it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', functi // 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(27); + // 28 statt 27 seit admin.mail-pace (Versandtakt) dazukam. + expect($routes)->toHaveCount(28); }); it('führt keinen Eintrag, dessen Route es nicht gibt', function () { diff --git a/tests/Feature/Admin/MailPacePageTest.php b/tests/Feature/Admin/MailPacePageTest.php new file mode 100644 index 0000000..54e3abb --- /dev/null +++ b/tests/Feature/Admin/MailPacePageTest.php @@ -0,0 +1,107 @@ +test(MailPace::class) + ->assertSee(MailLane::DIRECT) + ->assertSee(MailLane::URGENT) + ->assertSee(MailLane::CALM); +}); + +it('laesst den Betreiber eine Mail verschieben', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->call('move', InvoiceMail::class, MailLane::URGENT); + + expect(MailLane::for(InvoiceMail::class))->toBe(MailLane::URGENT); +}); + +it('verschiebt eine gesperrte Mail nicht, auch nicht ueber die Komponente', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->call('move', ResetPasswordMail::class, MailLane::CALM); + + expect(MailLane::for(ResetPasswordMail::class))->toBe(MailLane::DIRECT); +}); + +it('zeigt eine gesperrte Klasse als gesperrt statt mit einem Auswahlfeld', function () { + // Das Schloss ist nur die Höflichkeit vor der serverseitigen Ablehnung im + // Test darüber — aber eine Auswahl, die eine gesperrte Klasse anbietet, + // wäre die Einladung zu einem Klick, der ohnehin nichts bewirkt. + $html = Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->assertSee(class_basename(ResetPasswordMail::class)) + ->html(); + + expect($html)->not->toContain("move('".ResetPasswordMail::class."'"); +}); + +it('schaltet die Drossel ab und wieder an', function () { + $component = Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->call('togglePace'); + + expect(Settings::bool('mail.pace.enabled', true))->toBeFalse(); + + $component->call('togglePace'); + + expect(Settings::bool('mail.pace.enabled', true))->toBeTrue(); +}); + +/** + * Die zweite Hälfte der Untergrenze aus MailPaceServiceProvider:: + * mindestensEins() — die dortige liest nur ab, was hier gar nicht erst + * gespeichert werden darf. Ohne diese Prüfung käme eine eingetippte 0 durch + * bis zur Einstellung und würde erst beim nächsten Auftrag an der Lesestelle + * abgefangen, nachdem der Betreiber schon „gespeichert" gesehen hat. + */ +it('verweigert ein Kontingent von null oder weniger', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->set('urgentCount', 0) + ->call('savePace') + ->assertHasErrors(['urgentCount' => 'min']); + + expect(Settings::get('mail.pace.urgent.count'))->toBeNull(); +}); + +it('verweigert ein Fenster von null Minuten', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->set('calmMinutes', 0) + ->call('savePace') + ->assertHasErrors(['calmMinutes' => 'min']); + + expect(Settings::get('mail.pace.calm.minutes'))->toBeNull(); +}); + +it('speichert einen gueltigen Takt fuer beide gedrosselten Spuren', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailPace::class) + ->set('urgentCount', 12) + ->set('urgentMinutes', 3) + ->set('calmCount', 7) + ->set('calmMinutes', 9) + ->call('savePace') + ->assertHasNoErrors(); + + expect(Settings::get('mail.pace.urgent.count'))->toBe(12) + ->and(Settings::get('mail.pace.urgent.minutes'))->toBe(3) + ->and(Settings::get('mail.pace.calm.count'))->toBe(7) + ->and(Settings::get('mail.pace.calm.minutes'))->toBe(9); +});