diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php
index 60e1cc5..a4eb55c 100644
--- a/app/Livewire/Admin/Maintenance.php
+++ b/app/Livewire/Admin/Maintenance.php
@@ -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(),
]);
}
}
diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php
index aa5bc90..d6f40ac 100644
--- a/lang/de/maintenance.php
+++ b/lang/de/maintenance.php
@@ -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',
diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php
index 421567f..3a73935 100644
--- a/lang/en/maintenance.php
+++ b/lang/en/maintenance.php
@@ -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',
diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php
index 9350f38..850ee8e 100644
--- a/resources/views/components/ui/icon.blade.php
+++ b/resources/views/components/ui/icon.blade.php
@@ -18,6 +18,8 @@
'download' => '
{{ __('maintenance.subtitle') }}
-{{ $message }}
@enderror -{{ $message }}
@enderror +{{ $message }}
@enderror -{{ $dc->name }}
++ {{ $dc->name }} + @if ($dcSelected) + {{ $dcSelected }}/{{ $dcHosts->count() }} + @endif +
+ 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') }} +