feat(admin): provisioning liveness — per-run progress bar, last-activity, stale warning

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 18:11:17 +02:00
parent b50f97568f
commit be99f413f7
4 changed files with 66 additions and 13 deletions

View File

@ -60,6 +60,14 @@ class Provisioning extends Component
$this->dispatch('notify', message: __('admin.run_retried'));
}
/** A run that claims to be in progress but hasn't advanced in a while looks stuck. */
private function isStale(ProvisioningRun $run): bool
{
return in_array($run->status, ['running', 'waiting'], true)
&& $run->updated_at !== null
&& $run->updated_at->lt(now()->subMinutes(2));
}
private function subjectLabel(ProvisioningRun $run): string
{
$subject = $run->subject;
@ -84,16 +92,24 @@ 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(),
'rows' => $runs->map(function (ProvisioningRun $r) {
$total = max(count(config('provisioning.pipelines.'.$r->pipeline, [])), 1);
$done = $r->status === ProvisioningRun::STATUS_COMPLETED ? $total : $r->current_step;
return [
'uuid' => $r->uuid,
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.$total,
'percent' => (int) round($done / $total * 100),
'attempt' => $r->attempt,
'state' => $this->runState($r),
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
'activity' => $r->updated_at?->diffForHumans() ?? '—',
'stale' => $this->isStale($r),
];
})->all(),
'panel' => $active ? $this->panelFor($active) : null,
]);
}
@ -120,6 +136,9 @@ class Provisioning extends Component
'done' => $done,
'total' => $total,
'percent' => (int) round($done / $total * 100),
'activity' => $run->updated_at?->diffForHumans() ?? '—',
'started' => $run->started_at?->diffForHumans() ?? null,
'stale' => $this->isStale($run),
];
}
}

View File

@ -95,6 +95,10 @@ return [
'retry' => 'Erneut versuchen',
'run_retried' => 'Lauf wird erneut gestartet.',
'run_started' => 'Gestartet',
'run_activity' => 'Letzte Aktivität',
'run_stale_hint' => 'Seit über 2 Minuten kein Fortschritt — der Lauf hängt möglicherweise (in dieser Umgebung fehlt echte Infrastruktur).',
'state_stale' => 'Keine Aktivität',
'state_running' => 'Läuft',
'state_done' => 'Fertig',
'state_failed' => 'Fehlgeschlagen',

View File

@ -95,6 +95,10 @@ return [
'retry' => 'Retry',
'run_retried' => 'Run is being retried.',
'run_started' => 'Started',
'run_activity' => 'Last activity',
'run_stale_hint' => 'No progress for over 2 minutes — the run may be stuck (this environment has no real infrastructure).',
'state_stale' => 'No activity',
'state_running' => 'Running',
'state_done' => 'Done',
'state_failed' => 'Failed',

View File

@ -26,10 +26,22 @@
<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">
<div class="flex items-baseline gap-1.5">
<span class="text-xs text-body">{{ $r['step'] }}</span>
<span class="font-mono text-xs text-faint">{{ $r['n'] }}</span>
</div>
<div class="mt-1.5 flex items-center gap-2">
<div class="h-1 w-24 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $r['failed'] ? 'bg-danger' : ($r['stale'] ? 'bg-warning' : 'bg-accent') }} transition-[width] duration-500" style="width: {{ $r['percent'] }}%"></div>
</div>
<span class="font-mono text-[0.65rem] {{ $r['stale'] ? 'text-warning' : 'text-faint' }}">{{ $r['activity'] }}</span>
</div>
</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">
<x-ui.badge :status="$r['stale'] ? 'warning' : $tone">{{ $r['stale'] ? __('admin.state_stale') : __('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'] }}')"
@ -69,6 +81,20 @@
</div>
<p class="mt-1.5 font-mono text-xs text-muted">{{ __('admin.run_done', ['done' => $panel['done'], 'total' => $panel['total']]) }}</p>
<dl class="mt-3 space-y-1 text-xs">
@if ($panel['started'])
<div class="flex justify-between"><dt class="text-faint">{{ __('admin.run_started') }}</dt><dd class="font-mono text-muted">{{ $panel['started'] }}</dd></div>
@endif
<div class="flex justify-between"><dt class="text-faint">{{ __('admin.run_activity') }}</dt><dd class="font-mono {{ $panel['stale'] ? 'text-warning' : 'text-muted' }}">{{ $panel['activity'] }}</dd></div>
</dl>
@if ($panel['stale'] && ! $panel['failed'])
<div class="mt-3 flex items-start gap-2 rounded-lg border border-warning-border bg-warning-bg p-2.5">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />
<p class="text-xs text-body">{{ __('admin.run_stale_hint') }}</p>
</div>
@endif
@if ($panel['failed'])
<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>