feat(admin): maintenance windows — schedule once, assign many hosts, notify

- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 15:53:50 +02:00
parent c5d60340b7
commit eff8c08258
17 changed files with 746 additions and 0 deletions

View File

@ -0,0 +1,162 @@
<?php
namespace App\Livewire\Admin;
use App\Mail\MaintenanceAnnouncementMail;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\MaintenanceNotification;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Maintenance extends Component
{
#[Validate('required|string|max:255')]
public string $title = '';
#[Validate('nullable|string|max:2000')]
public string $publicDescription = '';
#[Validate('nullable|string|max:2000')]
public string $internalNotes = '';
#[Validate('required|date')]
public string $startsAt = '';
#[Validate('required|date')]
public string $endsAt = '';
/** @var array<int> */
public array $hostIds = [];
public function saveDraft(): void
{
$this->authorize('maintenance.manage');
$this->persist('draft');
}
public function publish(): void
{
$this->authorize('maintenance.manage');
$window = $this->persist('scheduled');
if ($window !== null) {
$this->sendAnnouncements($window);
}
}
private function persist(string $state): ?MaintenanceWindow
{
$data = $this->validate();
$starts = Carbon::parse($data['startsAt']);
$ends = Carbon::parse($data['endsAt']);
if ($ends->lessThanOrEqualTo($starts)) {
$this->addError('endsAt', __('maintenance.end_after_start'));
return null;
}
if ($state === 'scheduled') {
if (empty($this->hostIds)) {
$this->addError('hostIds', __('maintenance.need_host'));
return null;
}
if ($ends->isPast()) {
$this->addError('endsAt', __('maintenance.end_future'));
return null;
}
}
$window = MaintenanceWindow::create([
'title' => $data['title'],
'public_description' => $data['publicDescription'] ?: null,
'internal_notes' => $data['internalNotes'] ?: null,
'starts_at' => $starts,
'ends_at' => $ends,
'state' => $state,
'created_by' => auth()->id(),
'published_at' => $state === 'scheduled' ? now() : null,
]);
$window->hosts()->sync(array_map('intval', $this->hostIds));
$this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds');
$this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved'));
return $window;
}
public function publishExisting(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
if ($window === null || $window->state !== 'draft') {
return;
}
if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) {
$this->dispatch('notify', message: __('maintenance.need_host'));
return;
}
$window->update(['state' => 'scheduled', 'published_at' => now()]);
$this->sendAnnouncements($window);
$this->dispatch('notify', message: __('maintenance.published'));
}
public function cancel(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
if ($window === null || $window->state === 'cancelled') {
return;
}
$window->update(['state' => 'cancelled', 'cancelled_at' => now()]);
$this->dispatch('notify', message: __('maintenance.cancelled'));
}
/** Send the announcement to each affected customer exactly once (ledger-guarded). */
private function sendAnnouncements(MaintenanceWindow $window): void
{
foreach ($window->affectedCustomers() as $customer) {
$delivery = MaintenanceNotification::query()->firstOrCreate(
['maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement'],
['email' => $customer->email],
);
if ($delivery->wasRecentlyCreated) {
Mail::to($customer->email)->queue(new MaintenanceAnnouncementMail($window, $customer));
$delivery->update(['sent_at' => now()]);
}
}
}
public function render()
{
$windows = MaintenanceWindow::query()
->withCount('hosts')
->orderByDesc('starts_at')
->get()
->map(fn (MaintenanceWindow $w) => [
'uuid' => $w->uuid,
'title' => $w->title,
'starts_at' => $w->starts_at,
'ends_at' => $w->ends_at,
'state' => $w->derivedState(),
'hosts' => $w->hosts_count,
'affected' => $w->affectedCustomers()->count(),
'is_draft' => $w->state === 'draft',
'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true),
]);
return view('livewire.admin.maintenance', [
'windows' => $windows,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Mail;
use App\Models\Customer;
use App\Models\MaintenanceWindow;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: __('maintenance.mail_subject', ['title' => $this->window->title]));
}
public function content(): Content
{
return new Content(markdown: 'mail.maintenance-announcement', with: [
'title' => $this->window->title,
'description' => $this->window->public_description,
'startsAt' => $this->window->starts_at,
'endsAt' => $this->window->ends_at,
'name' => $this->customer->name,
]);
}
}

View File

@ -6,6 +6,7 @@ use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject; use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
@ -52,6 +53,11 @@ class Host extends Model implements ProvisioningSubject
return $this->hasMany(Instance::class); return $this->hasMany(Instance::class);
} }
public function maintenanceWindows(): BelongsToMany
{
return $this->belongsToMany(MaintenanceWindow::class);
}
/** Free committable storage: total minus reserve. */ /** Free committable storage: total minus reserve. */
public function freeGb(): int public function freeGb(): int
{ {

View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class MaintenanceNotification extends Model
{
protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at'];
protected function casts(): array
{
return ['sent_at' => 'datetime'];
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class MaintenanceWindow extends Model
{
/** @use HasFactory<\Database\Factories\MaintenanceWindowFactory> */
use HasFactory, HasUuid;
/** How far before the start the banner appears (Codex: 72 h). */
public const DISPLAY_HOURS = 72;
protected $fillable = [
'title', 'public_description', 'internal_notes', 'starts_at', 'ends_at',
'state', 'created_by', 'published_at', 'cancelled_at', 'cancellation_reason',
];
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'published_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
public function hosts(): BelongsToMany
{
return $this->belongsToMany(Host::class);
}
public function deliveries(): HasMany
{
return $this->hasMany(MaintenanceNotification::class);
}
/** Derived lifecycle state — never stored (Codex). */
public function derivedState(): string
{
if ($this->state === 'cancelled') {
return 'cancelled';
}
if ($this->state === 'draft') {
return 'draft';
}
if (now()->lt($this->starts_at)) {
return 'upcoming';
}
if (now()->lte($this->ends_at)) {
return 'active';
}
return 'completed';
}
public function isPublished(): bool
{
return $this->state === 'scheduled';
}
/** Service-bearing customers on the assigned hosts (recomputed live). */
public function affectedCustomers(): Collection
{
$hostIds = $this->hosts()->pluck('hosts.id');
if ($hostIds->isEmpty()) {
return collect();
}
return Customer::query()
->whereHas('instances', fn (Builder $q) => $q
->whereIn('host_id', $hostIds)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']))
->get();
}
/**
* Scheduled windows to show a given customer right now: within the display
* horizon (72 h before start until the end) and touching one of the
* customer's service instances' hosts.
*/
public static function bannerFor(Customer $customer): Collection
{
$hostIds = Instance::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->whereNotNull('host_id')
->pluck('host_id')->unique();
if ($hostIds->isEmpty()) {
return collect();
}
return self::query()
->where('state', 'scheduled')
->where('ends_at', '>=', now())
->where('starts_at', '<=', now()->addHours(self::DISPLAY_HOURS))
->whereHas('hosts', fn (Builder $q) => $q->whereIn('hosts.id', $hostIds))
->orderBy('starts_at')
->get();
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\MaintenanceWindow;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<MaintenanceWindow> */
class MaintenanceWindowFactory extends Factory
{
protected $model = MaintenanceWindow::class;
public function definition(): array
{
return [
'title' => 'Netzwerk-Wartung '.$this->faker->city(),
'public_description' => 'Kurzzeitige Nichtverfügbarkeit möglich.',
'internal_notes' => null,
'starts_at' => now()->addDays(2),
'ends_at' => now()->addDays(2)->addHours(2),
'state' => 'draft',
];
}
public function scheduled(): static
{
return $this->state(['state' => 'scheduled', 'published_at' => now()]);
}
public function active(): static
{
return $this->state(['state' => 'scheduled', 'published_at' => now(), 'starts_at' => now()->subHour(), 'ends_at' => now()->addHour()]);
}
}

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Maintenance windows: create once, assign to many hosts. Active/completed are
* DERIVED from state + starts_at/ends_at + now never persisted as status.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('maintenance_windows', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('title');
$table->text('public_description')->nullable();
$table->text('internal_notes')->nullable();
$table->timestamp('starts_at');
$table->timestamp('ends_at');
$table->string('state')->default('draft'); // draft | scheduled | cancelled
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('published_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->string('cancellation_reason')->nullable();
$table->timestamps();
});
Schema::create('host_maintenance_window', function (Blueprint $table) {
$table->id();
$table->foreignId('host_id')->constrained()->cascadeOnDelete();
$table->foreignId('maintenance_window_id')->constrained()->cascadeOnDelete();
$table->unique(['host_id', 'maintenance_window_id']);
});
// Idempotent notification ledger — one row per (window, customer, event).
Schema::create('maintenance_notifications', function (Blueprint $table) {
$table->id();
$table->foreignId('maintenance_window_id')->constrained()->cascadeOnDelete();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('event'); // announcement | cancelled
$table->string('email');
$table->timestamp('sent_at')->nullable();
$table->timestamps();
$table->unique(['maintenance_window_id', 'customer_id', 'event'], 'mw_notif_unique');
});
}
public function down(): void
{
Schema::dropIfExists('maintenance_notifications');
Schema::dropIfExists('host_maintenance_window');
Schema::dropIfExists('maintenance_windows');
}
};

View File

@ -12,6 +12,7 @@ return [
'hosts' => 'Hosts', 'hosts' => 'Hosts',
'datacenters' => 'Rechenzentren', 'datacenters' => 'Rechenzentren',
'provisioning' => 'Provisioning', 'provisioning' => 'Provisioning',
'maintenance' => 'Wartungen',
'revenue' => 'Umsatz', 'revenue' => 'Umsatz',
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
], ],

50
lang/de/maintenance.php Normal file
View File

@ -0,0 +1,50 @@
<?php
return [
'title' => 'Wartungen',
'subtitle' => 'Wartungsfenster anlegen, mehreren Hosts zuweisen und betroffene Kunden benachrichtigen.',
'empty' => 'Noch keine Wartungsfenster.',
'col_window' => 'Fenster',
'col_when' => 'Zeitraum',
'col_impact' => 'Betroffen',
'col_state' => 'Status',
'col_actions' => 'Aktionen',
'impact' => ':hosts Host(s) · :customers Kunde(n)',
'state_draft' => 'Entwurf',
'state_upcoming' => 'Geplant',
'state_active' => 'Aktiv',
'state_completed' => 'Abgeschlossen',
'state_cancelled' => 'Storniert',
'new_title' => 'Neues Wartungsfenster',
'field_title' => 'Titel',
'field_public' => 'Beschreibung für Kunden',
'field_internal' => 'Interne Notizen',
'field_start' => 'Beginn',
'field_end' => 'Ende',
'field_hosts' => 'Betroffene Hosts',
'save_draft' => 'Als Entwurf speichern',
'publish' => 'Veröffentlichen',
'publish_notify' => 'Veröffentlichen & benachrichtigen',
'cancel' => 'Stornieren',
'mail_note' => 'Beim Veröffentlichen erhalten betroffene Kunden eine Ankündigungs-E-Mail (aktuell Log/Mock).',
'draft_saved' => 'Entwurf gespeichert.',
'published' => 'Wartung veröffentlicht — Kunden werden benachrichtigt.',
'cancelled' => 'Wartung storniert.',
'end_after_start' => 'Das Ende muss nach dem Beginn liegen.',
'end_future' => 'Das Ende muss in der Zukunft liegen.',
'need_host' => 'Bitte mindestens einen Host auswählen.',
'banner_upcoming' => 'Geplante Wartung von :start bis :end — es ist nichts zu tun.',
'banner_active' => 'Wartung läuft — Ihre Cloud ist ggf. vorübergehend nicht erreichbar. Voraussichtliches Ende: :end.',
'mail_subject' => 'Geplante Wartung: :title',
'mail_heading' => 'Geplante Wartung',
'mail_greeting' => 'Hallo :name,',
'mail_window' => 'Zeitraum: :start bis :end.',
'mail_no_action' => 'Es ist nichts zu tun. Ihre Daten bleiben sicher.',
'mail_signoff' => 'Viele Grüße',
];

View File

@ -12,6 +12,7 @@ return [
'hosts' => 'Hosts', 'hosts' => 'Hosts',
'datacenters' => 'Datacenters', 'datacenters' => 'Datacenters',
'provisioning' => 'Provisioning', 'provisioning' => 'Provisioning',
'maintenance' => 'Maintenance',
'revenue' => 'Revenue', 'revenue' => 'Revenue',
'settings' => 'Settings', 'settings' => 'Settings',
], ],

50
lang/en/maintenance.php Normal file
View File

@ -0,0 +1,50 @@
<?php
return [
'title' => 'Maintenance',
'subtitle' => 'Create maintenance windows, assign them to multiple hosts and notify affected customers.',
'empty' => 'No maintenance windows yet.',
'col_window' => 'Window',
'col_when' => 'When',
'col_impact' => 'Affected',
'col_state' => 'Status',
'col_actions' => 'Actions',
'impact' => ':hosts host(s) · :customers customer(s)',
'state_draft' => 'Draft',
'state_upcoming' => 'Scheduled',
'state_active' => 'Active',
'state_completed' => 'Completed',
'state_cancelled' => 'Cancelled',
'new_title' => 'New maintenance window',
'field_title' => 'Title',
'field_public' => 'Customer-facing description',
'field_internal' => 'Internal notes',
'field_start' => 'Start',
'field_end' => 'End',
'field_hosts' => 'Affected hosts',
'save_draft' => 'Save as draft',
'publish' => 'Publish',
'publish_notify' => 'Publish & notify',
'cancel' => 'Cancel',
'mail_note' => 'On publish, affected customers receive an announcement email (currently log/mock).',
'draft_saved' => 'Draft saved.',
'published' => 'Maintenance published — customers are being notified.',
'cancelled' => 'Maintenance cancelled.',
'end_after_start' => 'The end must be after the start.',
'end_future' => 'The end must be in the future.',
'need_host' => 'Please select at least one host.',
'banner_upcoming' => 'Scheduled maintenance from :start to :end — no action needed.',
'banner_active' => 'Maintenance in progress — your cloud may be temporarily unavailable. Expected end: :end.',
'mail_subject' => 'Scheduled maintenance: :title',
'mail_heading' => 'Scheduled maintenance',
'mail_greeting' => 'Hello :name,',
'mail_window' => 'Window: :start to :end.',
'mail_no_action' => 'No action is required. Your data stays safe.',
'mail_signoff' => 'Best regards',
];

View File

@ -35,6 +35,7 @@
['admin.hosts', 'server', 'hosts'], ['admin.hosts', 'server', 'hosts'],
['admin.datacenters', 'database', 'datacenters'], ['admin.datacenters', 'database', 'datacenters'],
['admin.provisioning', 'activity', 'provisioning'], ['admin.provisioning', 'activity', 'provisioning'],
['admin.maintenance', 'alert-triangle', 'maintenance'],
['admin.revenue', 'trending-up', 'revenue'], ['admin.revenue', 'trending-up', 'revenue'],
] as [$route, $icon, $key]) ] as [$route, $icon, $key])
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)"> <x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">

View File

@ -11,6 +11,28 @@
@livewireStyles @livewireStyles
</head> </head>
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }"> <body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
@php
$maintenanceWindows = collect();
if (auth()->check()) {
$portalCustomer = \App\Models\Customer::query()->where('user_id', auth()->id())->first()
?? \App\Models\Customer::query()->where('email', auth()->user()->email)->first();
if ($portalCustomer) {
$maintenanceWindows = \App\Models\MaintenanceWindow::bannerFor($portalCustomer);
}
}
@endphp
@foreach ($maintenanceWindows as $mw)
@php $isActive = now()->greaterThanOrEqualTo($mw->starts_at) && now()->lessThanOrEqualTo($mw->ends_at); @endphp
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b {{ $isActive ? 'border-warning-border bg-warning-bg' : 'border-info-border bg-info-bg' }} px-4 py-2 text-center text-sm text-ink">
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 {{ $isActive ? 'text-warning' : 'text-info' }}" />
<span class="font-medium">{{ $mw->title }}</span>
<span class="text-muted">
{{ $isActive
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
: __('maintenance.banner_upcoming', ['start' => $mw->starts_at->isoFormat('LLL'), 'end' => $mw->ends_at->isoFormat('LT')]) }}
</span>
</div>
@endforeach
@if (session('impersonator_id')) @if (session('impersonator_id'))
{{-- Admin impersonation banner visible on every portal page until the admin returns. --}} {{-- Admin impersonation banner visible on every portal page until the admin returns. --}}
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm font-medium text-ink"> <div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm font-medium text-ink">

View File

@ -0,0 +1,105 @@
<div class="space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('maintenance.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('maintenance.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px] lg:items-start">
{{-- Windows list --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if ($windows->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('maintenance.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_window') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_when') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_impact') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('maintenance.col_state') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('maintenance.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($windows as $w)
@php $tone = ['draft' => 'info', 'upcoming' => 'provisioning', 'active' => 'warning', 'completed' => 'active', 'cancelled' => 'suspended'][$w['state']] ?? 'info'; @endphp
<tr wire:key="mw-{{ $w['uuid'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-medium text-ink">{{ $w['title'] }}</td>
<td class="px-4 py-3 text-xs text-body">{{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} {{ $w['ends_at']->isoFormat('HH:mm') }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ __('maintenance.impact', ['hosts' => $w['hosts'], 'customers' => $w['affected']]) }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$tone">{{ __('maintenance.state_'.$w['state']) }}</x-ui.badge></td>
<td class="px-4 py-3 text-right">
<div class="inline-flex gap-1.5">
@if ($w['is_draft'])
<button type="button" wire:click="publishExisting('{{ $w['uuid'] }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">{{ __('maintenance.publish') }}</button>
@endif
@if ($w['cancellable'])
<button type="button" wire:click="cancel('{{ $w['uuid'] }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('maintenance.cancel') }}</button>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- Create form --}}
<form wire:submit="publish" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2>
<x-ui.input name="title" wire:model="title" :label="__('maintenance.field_title')" />
<div>
<label class="text-sm font-medium text-body" for="publicDescription">{{ __('maintenance.field_public') }}</label>
<textarea id="publicDescription" wire:model="publicDescription" rows="2" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
</div>
<div>
<label class="text-sm font-medium text-body" for="internalNotes">{{ __('maintenance.field_internal') }}</label>
<textarea id="internalNotes" wire:model="internalNotes" rows="2" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body" for="startsAt">{{ __('maintenance.field_start') }}</label>
<input id="startsAt" type="datetime-local" wire:model="startsAt" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('startsAt')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="endsAt">{{ __('maintenance.field_end') }}</label>
<input id="endsAt" type="datetime-local" wire:model="endsAt" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('endsAt')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div>
<p class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</p>
@error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-1.5 max-h-48 space-y-3 overflow-y-auto rounded-md border border-line p-3">
@foreach ($datacenters as $dc)
@php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp
@if ($dcHosts->isNotEmpty())
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $dc->name }}</p>
<div class="mt-1 space-y-1">
@foreach ($dcHosts as $host)
<label class="flex items-center gap-2 text-sm text-body">
<input type="checkbox" wire:model="hostIds" value="{{ $host->id }}" class="rounded border-line-strong text-accent-active" />
<span class="font-mono">{{ $host->name }}</span>
</label>
@endforeach
</div>
</div>
@endif
@endforeach
</div>
</div>
<div class="flex flex-wrap justify-end gap-2">
<x-ui.button variant="secondary" type="button" wire:click="saveDraft" wire:loading.attr="disabled" wire:target="saveDraft">{{ __('maintenance.save_draft') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button>
</div>
<p class="text-xs text-faint">{{ __('maintenance.mail_note') }}</p>
</form>
</div>
</div>

View File

@ -0,0 +1,18 @@
<x-mail::message>
# {{ __('maintenance.mail_heading') }}
{{ __('maintenance.mail_greeting', ['name' => $name]) }}
**{{ $title }}**
{{ __('maintenance.mail_window', ['start' => $startsAt->isoFormat('LLLL'), 'end' => $endsAt->isoFormat('LLLL')]) }}
@if ($description)
{{ $description }}
@endif
{{ __('maintenance.mail_no_action') }}
{{ __('maintenance.mail_signoff') }}<br>
{{ config('app.name') }}
</x-mail::message>

View File

@ -63,6 +63,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
// POST so the identity change is CSRF-protected (a GET could be forced cross-site). // POST so the identity change is CSRF-protected (a GET could be forced cross-site).
Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate');
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance');
Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/settings', Admin\Settings::class)->name('settings'); Route::get('/settings', Admin\Settings::class)->name('settings');
}); });

View File

@ -0,0 +1,75 @@
<?php
use App\Livewire\Admin\Maintenance;
use App\Mail\MaintenanceAnnouncementMail;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\MaintenanceNotification;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
// operator() helper defined in RbacTest.
function affectedCustomerOn(Host $host): Customer
{
$customer = Customer::factory()->create();
Instance::factory()->create(['customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'active']);
return $customer;
}
it('publishes a window and emails affected customers once (idempotent)', function () {
Mail::fake();
$host = Host::factory()->active()->create();
$customer = affectedCustomerOn($host);
Livewire::actingAs(operator('Owner'))->test(Maintenance::class)
->set('title', 'Netz-Wartung')
->set('startsAt', now()->addDay()->format('Y-m-d\TH:i'))
->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i'))
->set('hostIds', [$host->id])
->call('publish')->assertHasNoErrors();
$window = MaintenanceWindow::query()->where('title', 'Netz-Wartung')->first();
expect($window)->not->toBeNull()->and($window->state)->toBe('scheduled')
->and($window->hosts()->count())->toBe(1);
Mail::assertQueued(MaintenanceAnnouncementMail::class, 1);
expect(MaintenanceNotification::query()->where('customer_id', $customer->id)->count())->toBe(1);
// The ledger's unique (window, customer, event) makes re-announcing a no-op.
expect(fn () => MaintenanceNotification::query()->create([
'maintenance_window_id' => $window->id, 'customer_id' => $customer->id, 'event' => 'announcement', 'email' => $customer->email,
]))->toThrow(Illuminate\Database\UniqueConstraintViolationException::class);
});
it('requires a host to publish', function () {
Livewire::actingAs(operator('Owner'))->test(Maintenance::class)
->set('title', 'X')
->set('startsAt', now()->addDay()->format('Y-m-d\TH:i'))
->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i'))
->set('hostIds', [])
->call('publish')->assertHasErrors(['hostIds']);
});
it('forbids non-privileged roles from managing maintenance', function () {
Livewire::actingAs(operator('Support'))->test(Maintenance::class)
->set('title', 'X')->set('startsAt', now()->addDay()->format('Y-m-d\TH:i'))
->set('endsAt', now()->addDay()->addHours(2)->format('Y-m-d\TH:i'))
->call('saveDraft')->assertForbidden();
});
it('shows the banner to an affected customer within the horizon', function () {
$host = Host::factory()->active()->create();
$customer = affectedCustomerOn($host);
$window = MaintenanceWindow::factory()->scheduled()->create(['starts_at' => now()->addDay(), 'ends_at' => now()->addDay()->addHour()]);
$window->hosts()->attach($host->id);
$banner = MaintenanceWindow::bannerFor($customer);
expect($banner)->toHaveCount(1)->and($banner->first()->id)->toBe($window->id);
// A customer on another host sees nothing.
$other = affectedCustomerOn(Host::factory()->active()->create());
expect(MaintenanceWindow::bannerFor($other))->toHaveCount(0);
});