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
parent
99081284a4
commit
08f61b43f5
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ return [
|
|||
'warning' => 'Warning',
|
||||
],
|
||||
|
||||
'retry' => 'Retry',
|
||||
'run_retried' => 'Run is being retried.',
|
||||
'state_running' => 'Running',
|
||||
'state_done' => 'Done',
|
||||
'state_failed' => 'Failed',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
sm:max-w-md
|
||||
md:max-w-xl
|
||||
lg:max-w-3xl
|
||||
xl:max-w-5xl
|
||||
2xl:max-w-7xl
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue