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
parent
c5d60340b7
commit
eff8c08258
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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)">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue