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
parent
65ba3d6588
commit
a9fbb395f9
|
|
@ -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 : '—',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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) : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ return [
|
|||
'state_running' => 'Läuft',
|
||||
'state_done' => 'Fertig',
|
||||
'state_failed' => 'Fehlgeschlagen',
|
||||
'no_runs' => 'Aktuell keine Bereitstellungen.',
|
||||
|
||||
'rev' => [
|
||||
'mrr' => 'MRR',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ return [
|
|||
'state_running' => 'Running',
|
||||
'state_done' => 'Done',
|
||||
'state_failed' => 'Failed',
|
||||
'no_runs' => 'No provisioning runs right now.',
|
||||
|
||||
'rev' => [
|
||||
'mrr' => 'MRR',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
Loading…
Reference in New Issue