diff --git a/app/Livewire/Admin/Provisioning.php b/app/Livewire/Admin/Provisioning.php
index 7b31884..0071092 100644
--- a/app/Livewire/Admin/Provisioning.php
+++ b/app/Livewire/Admin/Provisioning.php
@@ -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,
diff --git a/lang/de/admin.php b/lang/de/admin.php
index 101a76f..c39b7b0 100644
--- a/lang/de/admin.php
+++ b/lang/de/admin.php
@@ -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',
diff --git a/lang/en/admin.php b/lang/en/admin.php
index 5f5b427..616c6de 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -91,6 +91,8 @@ return [
'warning' => 'Warning',
],
+ 'retry' => 'Retry',
+ 'run_retried' => 'Run is being retried.',
'state_running' => 'Running',
'state_done' => 'Done',
'state_failed' => 'Failed',
diff --git a/resources/css/safelist-tailwindcss.txt b/resources/css/safelist-tailwindcss.txt
new file mode 100644
index 0000000..c99c89b
--- /dev/null
+++ b/resources/css/safelist-tailwindcss.txt
@@ -0,0 +1,5 @@
+sm:max-w-md
+md:max-w-xl
+lg:max-w-3xl
+xl:max-w-5xl
+2xl:max-w-7xl
diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php
index 09aad0e..df9a1c0 100644
--- a/resources/views/layouts/admin.blade.php
+++ b/resources/views/layouts/admin.blade.php
@@ -84,10 +84,16 @@
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"
>
diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php
index 51bc39a..e2faa49 100644
--- a/resources/views/layouts/portal-app.blade.php
+++ b/resources/views/layouts/portal-app.blade.php
@@ -92,13 +92,18 @@
{{-- Global toast — any element can trigger it with $dispatch('notify', { message }) --}}
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"
>
diff --git a/resources/views/livewire/admin/datacenters.blade.php b/resources/views/livewire/admin/datacenters.blade.php
index b95aa24..fb608ec 100644
--- a/resources/views/livewire/admin/datacenters.blade.php
+++ b/resources/views/livewire/admin/datacenters.blade.php
@@ -25,7 +25,7 @@
{{ $dc->code }}
@if ($editingUuid === $dc->uuid)
-
+
diff --git a/resources/views/livewire/admin/provisioning.blade.php b/resources/views/livewire/admin/provisioning.blade.php
index 2ca94ec..135a6c8 100644
--- a/resources/views/livewire/admin/provisioning.blade.php
+++ b/resources/views/livewire/admin/provisioning.blade.php
@@ -17,18 +17,29 @@
{{ __('admin.col.step') }}
{{ __('admin.col.attempt') }}
{{ __('admin.col.status') }}
+ {{ __('admin.col.actions') }}
@foreach ($rows as $r)
@php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp
-
+
{{ $r['customer'] }}
{{ $r['pipeline'] }}
{{ $r['step'] }}
{{ $r['n'] }}
{{ $r['attempt'] }}
{{ __('admin.state_'.$r['state']) }}
+
+ @if ($r['failed'])
+
+ {{ __('admin.retry') }}
+
+ @else
+ —
+ @endif
+
@endforeach
@@ -62,6 +73,10 @@
{{ __('admin.state_failed') }}
@if ($panel['error'])
{{ $panel['error'] }}
@endif
+
+ {{ __('admin.retry') }}
+
@elseif ($panel['current'])
diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php
index 6916e8c..cdab72a 100644
--- a/resources/views/vendor/wire-elements-modal/modal.blade.php
+++ b/resources/views/vendor/wire-elements-modal/modal.blade.php
@@ -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;"
>
diff --git a/tests/Feature/Admin/ProvisioningActionsTest.php b/tests/Feature/Admin/ProvisioningActionsTest.php
new file mode 100644
index 0000000..8a05474
--- /dev/null
+++ b/tests/Feature/Admin/ProvisioningActionsTest.php
@@ -0,0 +1,42 @@
+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);
+});