diff --git a/app/Livewire/Admin/Provisioning.php b/app/Livewire/Admin/Provisioning.php index 6ea53aa..5ad0d52 100644 --- a/app/Livewire/Admin/Provisioning.php +++ b/app/Livewire/Admin/Provisioning.php @@ -2,34 +2,59 @@ namespace App\Livewire\Admin; +use App\Livewire\Concerns\BuildsRunSteps; +use App\Models\Host; +use App\Models\Order; +use App\Models\ProvisioningRun; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Component; #[Layout('layouts.admin')] class Provisioning extends Component { + use BuildsRunSteps; + + #[On('echo-private:admin.runs,StepAdvanced')] + public function onStepAdvanced(): void + { + // A round-trip re-renders with fresh run state. + } + + private function subjectLabel(ProvisioningRun $run): string + { + $subject = $run->subject; + + if ($subject instanceof Order) { + return $subject->customer?->name ?? 'Order'; + } + if ($subject instanceof Host) { + return $subject->name; + } + + return class_basename($run->subject_type); + } + public function render() { + $runs = ProvisioningRun::query()->latest('id')->limit(30)->get(); + + $active = $runs->first(fn (ProvisioningRun $r) => in_array( + $r->status, ['pending', 'running', 'waiting'], true + )) ?? $runs->first(); + return view('livewire.admin.provisioning', [ - 'runs' => [ - ['customer' => 'Ordination Dr. Fux', 'pipeline' => 'customer', 'step' => 'DeployApplicationStack', 'n' => '8/15', 'attempt' => 1, 'state' => 'running'], - ['customer' => 'Architekturbüro Lang', 'pipeline' => 'customer', 'step' => 'ConfigureDnsAndTls', 'n' => '11/15', 'attempt' => 2, 'state' => 'running'], - ['customer' => 'Weingut Prantl', 'pipeline' => 'customer', 'step' => 'RunAcceptanceChecks', 'n' => '14/15', 'attempt' => 1, 'state' => 'running'], - ['customer' => 'Praxis Sommer', 'pipeline' => 'customer', 'step' => 'CloneVirtualMachine', 'n' => '3/15', 'attempt' => 3, 'state' => 'failed'], - ['customer' => 'Steuerberatung Reiss', 'pipeline' => 'customer', 'step' => 'CompleteProvisioning', 'n' => '15/15', 'attempt' => 1, 'state' => 'done'], - ], - 'steps' => [ - ['label' => 'ValidateOrder', 'state' => 'done'], - ['label' => 'ReserveResources', 'state' => 'done'], - ['label' => 'CloneVirtualMachine', 'state' => 'done'], - ['label' => 'ConfigureCloudInit', 'state' => 'done'], - ['label' => 'StartVirtualMachine', 'state' => 'done'], - ['label' => 'WaitForGuestAgent', 'state' => 'done'], - ['label' => 'ConfigureNetwork', 'state' => 'done'], - ['label' => 'DeployApplicationStack', 'state' => 'running'], - ['label' => 'ConfigureNextcloud', 'state' => 'pending'], - ['label' => 'RunAcceptanceChecks', 'state' => 'pending'], - ], + 'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true), + 'rows' => $runs->map(fn (ProvisioningRun $r) => [ + '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), + ])->all(), + 'steps' => $active ? $this->buildRunSteps($active) : [], + 'activeLabel' => $active ? $this->subjectLabel($active).' · '.$active->pipeline : '—', ]); } } diff --git a/app/Livewire/Concerns/BuildsRunSteps.php b/app/Livewire/Concerns/BuildsRunSteps.php new file mode 100644 index 0000000..4a5b4a9 --- /dev/null +++ b/app/Livewire/Concerns/BuildsRunSteps.php @@ -0,0 +1,56 @@ + */ + protected function buildRunSteps(?ProvisioningRun $run): array + { + if ($run === null) { + return []; + } + + $pipeline = config('provisioning.pipelines.'.$run->pipeline, []); + $current = $run->current_step; + $status = $run->status; + + $steps = []; + foreach ($pipeline as $index => $class) { + if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) { + $state = 'done'; + } elseif ($index === $current) { + $state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running'; + } else { + $state = 'pending'; + } + + $steps[] = ['label' => __(app($class)->label()), 'state' => $state]; + } + + return $steps; + } + + protected function currentStepLabel(ProvisioningRun $run): string + { + $pipeline = config('provisioning.pipelines.'.$run->pipeline, []); + $class = $pipeline[$run->current_step] ?? null; + + return $class ? __(app($class)->label()) : '—'; + } + + protected function runState(ProvisioningRun $run): string + { + return match ($run->status) { + ProvisioningRun::STATUS_COMPLETED => 'done', + ProvisioningRun::STATUS_FAILED => 'failed', + default => 'running', + }; + } +} diff --git a/app/Livewire/CustomerProvisioning.php b/app/Livewire/CustomerProvisioning.php new file mode 100644 index 0000000..b96b033 --- /dev/null +++ b/app/Livewire/CustomerProvisioning.php @@ -0,0 +1,50 @@ +user(); + if ($user === null) { + return null; + } + + $customer = Customer::query()->where('email', $user->email)->first(); + if ($customer === null) { + return null; + } + + return ProvisioningRun::query() + ->where('subject_type', Order::class) + ->whereIn('subject_id', $customer->orders()->select('id')) + ->latest('id') + ->first(); + } + + public function render() + { + $run = $this->currentRun(); + $active = $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED; + + return view('livewire.customer-provisioning', [ + 'active' => $active, + 'failed' => $run?->status === ProvisioningRun::STATUS_FAILED, + 'steps' => $active ? $this->buildRunSteps($run) : [], + ]); + } +} diff --git a/app/Provisioning/Events/StepAdvanced.php b/app/Provisioning/Events/StepAdvanced.php index 6416b7d..b152d3a 100644 --- a/app/Provisioning/Events/StepAdvanced.php +++ b/app/Provisioning/Events/StepAdvanced.php @@ -2,6 +2,8 @@ namespace App\Provisioning\Events; +use App\Models\Order; +use App\Models\ProvisioningRun; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; @@ -26,7 +28,18 @@ class StepAdvanced implements ShouldBroadcast /** @return array */ public function broadcastOn(): array { - return [new PrivateChannel('admin.runs')]; + $channels = [new PrivateChannel('admin.runs')]; + + // Scope customer runs to their own private channel for the dashboard. + $run = ProvisioningRun::query()->where('uuid', $this->runUuid)->first(); + if ($run !== null && $run->subject_type === Order::class) { + $customerId = $run->subject?->customer_id; + if ($customerId !== null) { + $channels[] = new PrivateChannel('customer.'.$customerId.'.run'); + } + } + + return $channels; } public function broadcastAs(): string diff --git a/lang/de/admin.php b/lang/de/admin.php index 1e81a8b..0285743 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -81,6 +81,7 @@ return [ 'state_running' => 'Läuft', 'state_done' => 'Fertig', 'state_failed' => 'Fehlgeschlagen', + 'no_runs' => 'Aktuell keine Bereitstellungen.', 'rev' => [ 'mrr' => 'MRR', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index d2102b3..7cef6da 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -110,4 +110,7 @@ return [ 'open_cloud' => 'Cloud öffnen', 'provisioning' => 'Bereitstellung', 'today_time' => 'Heute :time', + 'provisioning_title' => 'Ihre Cloud wird eingerichtet', + 'provisioning_sub' => 'Wir richten Ihre Cloud vollautomatisch ein. Der Zugang wird freigeschaltet, sobald alles bereit ist.', + 'provisioning_failed' => 'Bei der Einrichtung ist ein Problem aufgetreten. Unser Team kümmert sich darum.', ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 5e28dae..5c59b21 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -81,6 +81,7 @@ return [ 'state_running' => 'Running', 'state_done' => 'Done', 'state_failed' => 'Failed', + 'no_runs' => 'No provisioning runs right now.', 'rev' => [ 'mrr' => 'MRR', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 9578428..8ca1ec3 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -110,4 +110,7 @@ return [ 'open_cloud' => 'Open cloud', 'provisioning' => 'Provisioning', 'today_time' => 'Today :time', + 'provisioning_title' => 'Your cloud is being set up', + 'provisioning_sub' => 'We are provisioning your cloud automatically. Access unlocks as soon as everything is ready.', + 'provisioning_failed' => 'Something went wrong during setup. Our team is on it.', ]; diff --git a/resources/views/livewire/admin/provisioning.blade.php b/resources/views/livewire/admin/provisioning.blade.php index 7b7ff38..6dbe1e5 100644 --- a/resources/views/livewire/admin/provisioning.blade.php +++ b/resources/views/livewire/admin/provisioning.blade.php @@ -1,4 +1,4 @@ -
+

{{ __('admin.nav.provisioning') }}

{{ __('admin.provisioning_sub') }}

@@ -6,35 +6,45 @@
-
- - - - - - - - - - - @foreach ($runs as $r) - @php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp - - - - - + @if (count($rows) === 0) +

{{ __('admin.no_runs') }}

+ @else +
+
{{ __('admin.col.customer') }}{{ __('admin.col.step') }}{{ __('admin.col.attempt') }}{{ __('admin.col.status') }}
{{ $r['customer'] }}{{ $r['step'] }} {{ $r['n'] }}{{ $r['attempt'] }}{{ __('admin.state_'.$r['state']) }}
+ + + + + + - @endforeach - -
{{ __('admin.col.customer') }}{{ __('admin.col.step') }}{{ __('admin.col.attempt') }}{{ __('admin.col.status') }}
-
+ + + @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']) }} + + @endforeach + + +
+ @endif

{{ __('admin.run_detail') }}

-

ordination-fux · customer

- +

{{ $activeLabel }}

+ @if (count($steps) > 0) + + @else +

{{ __('admin.no_runs') }}

+ @endif
diff --git a/resources/views/livewire/customer-provisioning.blade.php b/resources/views/livewire/customer-provisioning.blade.php new file mode 100644 index 0000000..6392a2e --- /dev/null +++ b/resources/views/livewire/customer-provisioning.blade.php @@ -0,0 +1,14 @@ +
+ @if ($active) +
+
+ +

{{ __('dashboard.provisioning_title') }}

+
+

+ {{ $failed ? __('dashboard.provisioning_failed') : __('dashboard.provisioning_sub') }} +

+ +
+ @endif +
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 7574f3b..380164b 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -8,6 +8,9 @@

{{ __('dashboard.as_of', ['time' => '08:42']) }}

+ {{-- Live provisioning progress (only while a run is in flight) --}} + + {{-- KPI row --}}
{{-- Storage ring --}} diff --git a/routes/channels.php b/routes/channels.php index 18b4ea1..052e3b9 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -8,3 +8,11 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) { // Operator console live provisioning feed — admins only. Broadcast::channel('admin.runs', fn ($user) => (bool) $user->is_admin); + +// A customer's own provisioning feed — bridged from the auth user by email. +Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { + return \App\Models\Customer::query() + ->whereKey($customerId) + ->where('email', $user->email) + ->exists(); +}); diff --git a/tests/Feature/Provisioning/LiveProgressTest.php b/tests/Feature/Provisioning/LiveProgressTest.php new file mode 100644 index 0000000..e1cf0f6 --- /dev/null +++ b/tests/Feature/Provisioning/LiveProgressTest.php @@ -0,0 +1,53 @@ +create(); + ProvisioningRun::factory()->create([ + 'subject_type' => Order::class, 'subject_id' => $order->id, + 'pipeline' => 'customer', 'status' => 'running', 'current_step' => 7, + ]); + + $this->actingAs(User::factory()->create(['is_admin' => true])) + ->get(route('admin.provisioning')) + ->assertOk() + ->assertSee($order->customer->name); +}); + +it('gates the provisioning console to admins', function () { + $this->get(route('admin.provisioning'))->assertRedirect('/login'); + $this->actingAs(User::factory()->create(['is_admin' => false])) + ->get(route('admin.provisioning'))->assertForbidden(); +}); + +it('shows the customer their own live provisioning progress', function () { + $user = User::factory()->create(['email' => 'k@example.test', 'is_admin' => false]); + $customer = Customer::factory()->create(['email' => 'k@example.test']); + $order = Order::factory()->create(['customer_id' => $customer->id]); + ProvisioningRun::factory()->create([ + 'subject_type' => Order::class, 'subject_id' => $order->id, + 'pipeline' => 'customer', 'status' => 'running', 'current_step' => 3, + ]); + + Livewire::actingAs($user)->test(CustomerProvisioning::class) + ->assertSee(__('dashboard.provisioning_title')); +}); + +it('renders nothing once the customer provisioning has completed', function () { + $user = User::factory()->create(['email' => 'done@example.test']); + $customer = Customer::factory()->create(['email' => 'done@example.test']); + $order = Order::factory()->create(['customer_id' => $customer->id]); + ProvisioningRun::factory()->create([ + 'subject_type' => Order::class, 'subject_id' => $order->id, + 'pipeline' => 'customer', 'status' => 'completed', 'current_step' => 14, + ]); + + Livewire::actingAs($user)->test(CustomerProvisioning::class) + ->assertDontSee(__('dashboard.provisioning_title')); +});