fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action

- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 15:29:37 +02:00
parent 99081284a4
commit 08f61b43f5
10 changed files with 120 additions and 9 deletions

View File

@ -4,8 +4,10 @@ namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
@ -21,6 +23,35 @@ class Provisioning extends Component
// A round-trip re-renders with fresh run state.
}
/** Retry a failed run from its current step (mirrors the host-detail retry). */
public function retry(string $uuid): void
{
$run = ProvisioningRun::query()->where('uuid', $uuid)->first();
if ($run === null || $run->status !== ProvisioningRun::STATUS_FAILED) {
return;
}
$run->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
// Move the subject out of its error state so the console reflects the retry.
$subject = $run->subject;
if ($subject instanceof Host) {
$subject->update(['status' => 'onboarding']);
} elseif ($subject instanceof Order) {
$subject->update(['status' => 'provisioning']);
Instance::query()->where('order_id', $subject->id)->where('status', 'failed')->update(['status' => 'provisioning']);
}
AdvanceRunJob::dispatch($run->uuid);
$this->dispatch('notify', message: __('admin.run_retried'));
}
private function subjectLabel(ProvisioningRun $run): string
{
$subject = $run->subject;
@ -46,12 +77,14 @@ class Provisioning extends Component
return view('livewire.admin.provisioning', [
'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true),
'rows' => $runs->map(fn (ProvisioningRun $r) => [
'uuid' => $r->uuid,
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.count(config('provisioning.pipelines.'.$r->pipeline, [])),
'attempt' => $r->attempt,
'state' => $this->runState($r),
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
])->all(),
'panel' => $active ? $this->panelFor($active) : null,
]);
@ -67,6 +100,7 @@ class Provisioning extends Component
$done = $completed ? $total : $current;
return [
'uuid' => $run->uuid,
'subject' => $this->subjectLabel($run),
'pipeline' => $run->pipeline,
'attempt' => $run->attempt,

View File

@ -91,6 +91,8 @@ return [
'warning' => 'Warnung',
],
'retry' => 'Erneut versuchen',
'run_retried' => 'Lauf wird erneut gestartet.',
'state_running' => 'Läuft',
'state_done' => 'Fertig',
'state_failed' => 'Fehlgeschlagen',

View File

@ -91,6 +91,8 @@ return [
'warning' => 'Warning',
],
'retry' => 'Retry',
'run_retried' => 'Run is being retried.',
'state_running' => 'Running',
'state_done' => 'Done',
'state_failed' => 'Failed',

View File

@ -0,0 +1,5 @@
sm:max-w-md
md:max-w-xl
lg:max-w-3xl
xl:max-w-5xl
2xl:max-w-7xl

View File

@ -84,10 +84,16 @@
<div
x-data="{ show: false, msg: '', timer: null }"
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 2400)"
x-show="show" x-cloak x-transition.opacity.duration.200ms
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 3200)"
x-show="show" x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-12"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
role="status" aria-live="polite"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md"
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md"
>
<span x-text="msg"></span>
</div>

View File

@ -92,13 +92,18 @@
{{-- Global toast any element can trigger it with $dispatch('notify', { message }) --}}
<div
x-data="{ show: false, msg: '', timer: null }"
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 2400)"
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 3200)"
x-show="show"
x-cloak
x-transition.opacity.duration.200ms
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-12"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
role="status"
aria-live="polite"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
>
<span x-text="msg"></span>
</div>

View File

@ -25,7 +25,7 @@
<tr wire:key="dc-{{ $dc->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td>
@if ($editingUuid === $dc->uuid)
<td class="px-4 py-2" colspan="2">
<td class="px-4 py-2" colspan="3">
<div class="flex flex-wrap items-center gap-2">
<input type="text" wire:model="editName" aria-label="{{ __('datacenters.name') }}"
class="min-w-32 flex-1 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-ink" />

View File

@ -17,18 +17,29 @@
<th class="px-4 py-3 font-semibold">{{ __('admin.col.step') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.attempt') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('admin.col.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
@php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<tr wire:key="run-{{ $r['uuid'] }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-medium text-ink">{{ $r['customer'] }}
<span class="ml-1 font-mono text-xs text-faint">{{ $r['pipeline'] }}</span></td>
<td class="px-4 py-3"><span class="text-xs text-body">{{ $r['step'] }}</span>
<span class="font-mono text-xs text-faint">{{ $r['n'] }}</span></td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['attempt'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$tone">{{ __('admin.state_'.$r['state']) }}</x-ui.badge></td>
<td class="px-4 py-3 text-right">
@if ($r['failed'])
<button type="button" wire:click="retry('{{ $r['uuid'] }}')" wire:loading.attr="disabled" wire:target="retry('{{ $r['uuid'] }}')"
class="inline-flex items-center gap-1.5 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">
<x-ui.icon name="rotate-ccw" class="size-3.5" />{{ __('admin.retry') }}
</button>
@else
<span class="text-xs text-faint"></span>
@endif
</td>
</tr>
@endforeach
</tbody>
@ -62,6 +73,10 @@
<div class="mt-4 rounded-lg border border-danger-border bg-danger-bg p-3">
<p class="text-xs font-semibold text-danger">{{ __('admin.state_failed') }}</p>
@if ($panel['error'])<p class="mt-0.5 break-words text-xs text-body">{{ $panel['error'] }}</p>@endif
<button type="button" wire:click="retry('{{ $panel['uuid'] }}')" wire:loading.attr="disabled" wire:target="retry('{{ $panel['uuid'] }}')"
class="mt-3 inline-flex items-center gap-1.5 rounded-md border border-danger-border px-2.5 py-1.5 text-xs font-semibold text-danger hover:bg-danger/10">
<x-ui.icon name="rotate-ccw" class="size-3.5" />{{ __('admin.retry') }}
</button>
</div>
@elseif ($panel['current'])
<div class="mt-4">

View File

@ -11,7 +11,7 @@
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-10 overflow-y-auto"
class="fixed inset-0 z-[70] overflow-y-auto"
style="display: none;"
>
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">

View File

@ -0,0 +1,42 @@
<?php
use App\Livewire\Admin\Provisioning;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
// admin() helper defined in HostManagementTest.
it('retries a failed run from the provisioning console', function () {
Queue::fake();
$host = Host::factory()->create(['status' => 'error']);
$run = ProvisioningRun::factory()->create([
'subject_type' => Host::class, 'subject_id' => $host->id, 'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_FAILED, 'attempt' => 3, 'current_step' => 4, 'error' => 'boom',
]);
Livewire::actingAs(admin())->test(Provisioning::class)->call('retry', $run->uuid);
$run->refresh();
expect($run->status)->toBe(ProvisioningRun::STATUS_RUNNING)
->and($run->attempt)->toBe(0)
->and($run->error)->toBeNull()
->and($host->fresh()->status)->toBe('onboarding');
Queue::assertPushed(AdvanceRunJob::class);
});
it('ignores retry on a run that is not failed', function () {
Queue::fake();
$host = Host::factory()->active()->create();
$run = ProvisioningRun::factory()->create([
'subject_type' => Host::class, 'subject_id' => $host->id, 'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_RUNNING,
]);
Livewire::actingAs(admin())->test(Provisioning::class)->call('retry', $run->uuid);
expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_RUNNING);
Queue::assertNotPushed(AdvanceRunJob::class);
});