fix(maintenance): make the window form usable

The action labels wrapped to two lines and crushed the column — now icon
buttons with tooltips, like every other admin table. The form column was too
narrow for its content: the datetime fields silently clipped their own value,
so they are stacked and full width now, with duration chips because typing an
end timestamp by hand is the fiddliest part of the form. Host rows show a real
selected state and a per-datacenter count, and the impact column says
"1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:05:33 +02:00
parent 7ef1b3e5c8
commit 7f60f88542
6 changed files with 167 additions and 26 deletions

View File

@ -45,6 +45,52 @@ class Maintenance extends Component
: array_values(array_unique([...$current, ...$ids]));
}
/**
* Set the end from the start. Typing a full timestamp by hand is the
* fiddliest part of this form, and the end is almost always "start plus a
* round number of minutes".
*/
public function setDuration(int $minutes): void
{
$this->authorize('maintenance.manage');
$start = $this->parsed($this->startsAt);
if ($start === null) {
// No start yet: assume the next half hour, which is what someone
// scheduling a window in a hurry means anyway.
$start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute();
$this->startsAt = $start->format('Y-m-d\TH:i');
}
$this->endsAt = $start->copy()->addMinutes($minutes)->format('Y-m-d\TH:i');
}
/** Minutes between start and end, or null while either is unusable. */
public function durationMinutes(): ?int
{
$start = $this->parsed($this->startsAt);
$end = $this->parsed($this->endsAt);
if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) {
return null;
}
return (int) $start->diffInMinutes($end);
}
private function parsed(string $value): ?\Illuminate\Support\Carbon
{
if (trim($value) === '') {
return null;
}
try {
return \Illuminate\Support\Carbon::parse($value);
} catch (\Throwable) {
return null; // half-typed input is normal while the field is live
}
}
public function saveDraft(): void
{
$this->authorize('maintenance.manage');
@ -196,6 +242,7 @@ class Maintenance extends Component
'windows' => $windows,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
'durationMinutes' => $this->durationMinutes(),
]);
}
}

View File

@ -10,7 +10,8 @@ return [
'col_impact' => 'Betroffen',
'col_state' => 'Status',
'col_actions' => 'Aktionen',
'impact' => ':hosts Host(s) · :customers Kunde(n)',
'impact_hosts' => '{0} keine Hosts|{1} 1 Host|[2,*] :count Hosts',
'impact_customers' => '{0} keine Kunden|{1} 1 Kunde|[2,*] :count Kunden',
'state_draft' => 'Entwurf',
'state_upcoming' => 'Geplant',
@ -24,6 +25,9 @@ return [
'field_public_hint' => 'Dieser Text erscheint im Kundenportal und in der E-Mail.',
'field_internal_ph' => 'Nur für das Team sichtbar.',
'selected' => ':n ausgewählt',
'duration' => 'Dauer',
'duration_value' => ':n min',
'select_none' => 'Keine',
'select_all' => 'Alle',
'field_title' => 'Titel',
'field_public' => 'Beschreibung für Kunden',

View File

@ -10,7 +10,8 @@ return [
'col_impact' => 'Affected',
'col_state' => 'Status',
'col_actions' => 'Actions',
'impact' => ':hosts host(s) · :customers customer(s)',
'impact_hosts' => '{0} no hosts|{1} 1 host|[2,*] :count hosts',
'impact_customers' => '{0} no customers|{1} 1 customer|[2,*] :count customers',
'state_draft' => 'Draft',
'state_upcoming' => 'Scheduled',
@ -24,6 +25,9 @@ return [
'field_public_hint' => 'This text appears in the customer portal and the email.',
'field_internal_ph' => 'Visible to your team only.',
'selected' => ':n selected',
'duration' => 'Duration',
'duration_value' => ':n min',
'select_none' => 'None',
'select_all' => 'All',
'field_title' => 'Title',
'field_public' => 'Customer-facing description',

View File

@ -18,6 +18,8 @@
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
'check' => '<polyline points="20 6 9 17 4 12"/>',
'bell' => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
'calendar' => '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
'qr-code' => '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
'lock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'unlock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',

View File

@ -4,7 +4,7 @@
<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">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px] 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())
@ -27,18 +27,36 @@
<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 text-xs text-muted whitespace-nowrap">
{{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }}
<span class="text-faint">·</span>
{{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $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">
{{-- Icon buttons: the labels wrapped to two lines and made the
column unreadable. Same pattern as the other admin tables. --}}
<div class="inline-flex gap-1">
@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>
<button type="button" wire:click="publishExisting('{{ $w['uuid'] }}')"
title="{{ __('maintenance.publish') }}" aria-label="{{ __('maintenance.publish') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="check" class="size-4" />
</button>
@endif
@if ($w['notifiable'])
<button type="button" wire:click="resend('{{ $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.notify_again') }}</button>
<button type="button" wire:click="resend('{{ $w['uuid'] }}')"
title="{{ __('maintenance.notify_again') }}" aria-label="{{ __('maintenance.notify_again') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="bell" class="size-4" />
</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>
<button type="button" wire:click="cancel('{{ $w['uuid'] }}')"
title="{{ __('maintenance.cancel') }}" aria-label="{{ __('maintenance.cancel') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
<x-ui.icon name="x" class="size-4" />
</button>
@endif
</div>
</td>
@ -73,17 +91,38 @@
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-3">
<div class="grid grid-cols-1 gap-3">
@foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key])
<div>
<label class="text-sm font-medium text-body" for="{{ $field }}">{{ __('maintenance.'.$key) }}</label>
<input id="{{ $field }}" type="datetime-local" wire:model="{{ $field }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 [color-scheme:dark]" />
<div class="relative mt-1.5">
<x-ui.icon name="calendar" class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-faint" />
<input id="{{ $field }}" type="datetime-local" wire:model.live="{{ $field }}"
class="w-full rounded-md border border-line-strong bg-surface py-2 pl-9 pr-3 text-sm text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 [color-scheme:dark]" />
</div>
@error($field)<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@endforeach
</div>
{{-- Typing an end time by hand is the fiddliest part of this form;
these set it from the start time. --}}
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-xs text-faint">{{ __('maintenance.duration') }}</span>
@foreach ([30, 60, 120, 240] as $minutes)
<button type="button" wire:click="setDuration({{ $minutes }})"
class="rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
{{ $durationMinutes === $minutes ? 'border-accent-border bg-accent-bg text-accent-text' : 'border-line text-muted hover:border-accent-border hover:text-accent-text' }}">
{{ $minutes < 60 ? $minutes.' min' : ($minutes / 60).' h' }}
</button>
@endforeach
@if ($durationMinutes !== null && ! in_array($durationMinutes, [30, 60, 120, 240], true))
<span class="rounded-pill border border-line-strong px-2.5 py-0.5 text-xs text-muted">{{ __('maintenance.duration_value', ['n' => $durationMinutes]) }}</span>
@endif
</div>
</div>
{{-- Host picker, grouped by datacenter --}}
<div>
<div class="flex items-baseline justify-between">
@ -91,23 +130,35 @@
<span class="font-mono text-xs {{ count($hostIds) > 0 ? 'text-accent-text' : 'text-faint' }}">{{ __('maintenance.selected', ['n' => count($hostIds)]) }}</span>
</div>
@error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-1.5 max-h-56 divide-y divide-line overflow-y-auto rounded-md border border-line-strong">
<div class="mt-1.5 max-h-64 divide-y divide-line overflow-y-auto rounded-lg border border-line-strong bg-surface-2">
@foreach ($datacenters as $dc)
@php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp
@php
$dcHosts = $hosts->where('datacenter', $dc->code);
$dcSelected = $dcHosts->whereIn('id', $hostIds)->count();
@endphp
@if ($dcHosts->isNotEmpty())
<div class="p-3">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $dc->name }}</p>
<div class="p-2.5">
<div class="flex items-center justify-between px-1.5 pb-1.5">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">
{{ $dc->name }}
@if ($dcSelected)
<span class="ml-1 font-mono text-accent-text">{{ $dcSelected }}/{{ $dcHosts->count() }}</span>
@endif
</p>
<button type="button" wire:click="selectDatacenter('{{ $dc->code }}')"
class="text-xs font-semibold text-accent-text hover:underline">{{ __('maintenance.select_all') }}</button>
class="rounded-pill border border-line px-2 py-0.5 text-[11px] font-semibold text-muted transition hover:border-accent-border hover:text-accent-text">
{{ $dcSelected === $dcHosts->count() ? __('maintenance.select_none') : __('maintenance.select_all') }}
</button>
</div>
<div class="mt-2 space-y-1">
<div class="space-y-1">
@foreach ($dcHosts as $host)
<label class="flex cursor-pointer items-center gap-2.5 rounded px-1.5 py-1 text-sm text-body hover:bg-surface-hover">
@php $checked = in_array($host->id, $hostIds); @endphp
<label class="flex cursor-pointer items-center gap-2.5 rounded-md border px-2.5 py-2 text-sm transition
{{ $checked ? 'border-accent-border bg-accent-bg text-ink' : 'border-transparent text-body hover:border-line hover:bg-surface-hover' }}">
<input type="checkbox" wire:model.live="hostIds" value="{{ $host->id }}"
class="size-4 rounded border-line-strong bg-surface text-accent-active focus:ring-2 focus:ring-accent/30" />
<span class="font-mono">{{ $host->name }}</span>
<span class="ml-auto font-mono text-xs text-faint">{{ $host->public_ip }}</span>
<span class="ml-auto font-mono text-xs {{ $checked ? 'text-accent-text' : 'text-faint' }}">{{ $host->public_ip }}</span>
</label>
@endforeach
</div>

View File

@ -110,3 +110,36 @@ it('shows the banner to an affected customer within the horizon', function () {
$other = affectedCustomerOn(Host::factory()->active()->create());
expect(MaintenanceWindow::bannerFor($other))->toHaveCount(0);
});
it('fills the end time from a duration chip', function () {
$owner = operator('Owner');
// With a start set, the chip just adds the minutes.
$component = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class)
->set('startsAt', '2026-08-01T22:00')
->call('setDuration', 120);
expect($component->get('endsAt'))->toBe('2026-08-02T00:00')
->and($component->instance()->durationMinutes())->toBe(120);
// Without one, it starts at the next half hour rather than doing nothing.
$fresh = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class)
->call('setDuration', 60);
expect($fresh->get('startsAt'))->not->toBe('')
->and($fresh->instance()->durationMinutes())->toBe(60);
});
it('reports no duration while the times are unusable', function () {
$component = Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Maintenance::class);
expect($component->instance()->durationMinutes())->toBeNull();
// A half-typed value must not blow up the live-updating form.
$component->set('startsAt', '2026-08-')->set('endsAt', '');
expect($component->instance()->durationMinutes())->toBeNull();
// An end before the start is not a duration either.
$component->set('startsAt', '2026-08-01T22:00')->set('endsAt', '2026-08-01T21:00');
expect($component->instance()->durationMinutes())->toBeNull();
});