feat(engine-b): live provisioning progress (admin console + customer dashboard)

Admin /admin/provisioning bound to real runs+steps (poll+admin.runs). Embedded
CustomerProvisioning card shows the logged-in customer their own run live
(per-customer StepAdvanced channel, email-bridged authz). Shared BuildsRunSteps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 11:57:35 +02:00
parent 65ba3d6588
commit a9fbb395f9
13 changed files with 285 additions and 45 deletions

View File

@ -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 : '—',
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\ProvisioningRun;
/**
* Builds the {label, state} array a progress stepper renders from a run's
* pipeline + current step + status. Shared by admin and customer views.
*/
trait BuildsRunSteps
{
/** @return array<int, array{label: string, state: string}> */
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',
};
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Livewire\Component;
/**
* Embedded live provisioning progress for the logged-in customer's order.
* Polls only itself so the dashboard's charts don't churn. Renders nothing once
* provisioning has completed.
*/
class CustomerProvisioning extends Component
{
use BuildsRunSteps;
private function currentRun(): ?ProvisioningRun
{
$user = auth()->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) : [],
]);
}
}

View File

@ -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<int, PrivateChannel> */
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

View File

@ -81,6 +81,7 @@ return [
'state_running' => 'Läuft',
'state_done' => 'Fertig',
'state_failed' => 'Fehlgeschlagen',
'no_runs' => 'Aktuell keine Bereitstellungen.',
'rev' => [
'mrr' => 'MRR',

View File

@ -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.',
];

View File

@ -81,6 +81,7 @@ return [
'state_running' => 'Running',
'state_done' => 'Done',
'state_failed' => 'Failed',
'no_runs' => 'No provisioning runs right now.',
'rev' => [
'mrr' => 'MRR',

View File

@ -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.',
];

View File

@ -1,4 +1,4 @@
<div class="space-y-5">
<div class="space-y-5" @if ($hasActive) wire:poll.4s @endif>
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.provisioning') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.provisioning_sub') }}</p>
@ -6,35 +6,45 @@
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<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>
</tr>
</thead>
<tbody>
@foreach ($runs 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">
<td class="px-4 py-3 font-medium text-ink">{{ $r['customer'] }}</td>
<td class="px-4 py-3"><span class="font-mono 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>
@if (count($rows) === 0)
<p class="p-8 text-center text-sm text-muted">{{ __('admin.no_runs') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<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>
</tr>
@endforeach
</tbody>
</table>
</div>
</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">
<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>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.run_detail') }}</h2>
<p class="mb-3 font-mono text-xs text-muted">ordination-fux · customer</p>
<x-ui.progress-stepper :steps="$steps" />
<p class="mb-3 font-mono text-xs text-muted">{{ $activeLabel }}</p>
@if (count($steps) > 0)
<x-ui.progress-stepper :steps="$steps" />
@else
<p class="text-sm text-muted">{{ __('admin.no_runs') }}</p>
@endif
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
<div @if ($active) wire:poll.4s @endif>
@if ($active)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise">
<div class="flex items-center gap-2">
<span class="size-2 rounded-pill {{ $failed ? 'bg-danger' : 'bg-info animate-pulse' }}" aria-hidden="true"></span>
<h2 class="font-semibold text-ink">{{ __('dashboard.provisioning_title') }}</h2>
</div>
<p class="mb-4 mt-1 text-sm text-muted">
{{ $failed ? __('dashboard.provisioning_failed') : __('dashboard.provisioning_sub') }}
</p>
<x-ui.progress-stepper :steps="$steps" />
</div>
@endif
</div>

View File

@ -8,6 +8,9 @@
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p>
</div>
{{-- Live provisioning progress (only while a run is in flight) --}}
<livewire:customer-provisioning />
{{-- KPI row --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{{-- Storage ring --}}

View File

@ -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();
});

View File

@ -0,0 +1,53 @@
<?php
use App\Livewire\CustomerProvisioning;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\User;
use Livewire\Livewire;
it('shows real provisioning runs to admins', function () {
$order = Order::factory()->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'));
});