feat(113): Platform Ops Runbooks — UX Polish (Filament-native, system theme, live scope) #137
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\System\Pages\Ops;
|
namespace App\Filament\System\Pages\Ops;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Auth\BreakGlassSession;
|
use App\Services\Auth\BreakGlassSession;
|
||||||
@ -59,6 +60,21 @@ public function scopeLabel(): string
|
|||||||
return $this->tenantId !== null ? "Single tenant (#{$this->tenantId})" : 'Single tenant';
|
return $this->tenantId !== null ? "Single tenant (#{$this->tenantId})" : 'Single tenant';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function lastRun(): ?OperationRun
|
||||||
|
{
|
||||||
|
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||||
|
|
||||||
|
if (! $platformTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('workspace_id', (int) $platformTenant->workspace_id)
|
||||||
|
->where('type', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
public function selectedTenantName(): ?string
|
public function selectedTenantName(): ?string
|
||||||
{
|
{
|
||||||
if ($this->tenantId === null) {
|
if ($this->tenantId === null) {
|
||||||
@ -192,6 +208,7 @@ private function scopeForm(): array
|
|||||||
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
|
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
|
||||||
])
|
])
|
||||||
->default($this->scopeMode)
|
->default($this->scopeMode)
|
||||||
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
Select::make('tenant_id')
|
Select::make('tenant_id')
|
||||||
|
|||||||
@ -58,6 +58,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
]);
|
])
|
||||||
|
->viteTheme('resources/css/filament/system/theme.css');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
resources/css/filament/system/theme.css
Normal file
4
resources/css/filament/system/theme.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||||
|
|
||||||
|
@source '../../../../app/Filament/System/**/*';
|
||||||
|
@source '../../../../resources/views/filament/system/**/*.blade.php';
|
||||||
@ -1,59 +1,129 @@
|
|||||||
|
@php
|
||||||
|
$lastRun = $this->lastRun();
|
||||||
|
$lastRunStatusSpec = $lastRun
|
||||||
|
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $lastRun->status)
|
||||||
|
: null;
|
||||||
|
$lastRunOutcomeSpec = $lastRun && (string) $lastRun->status === 'completed'
|
||||||
|
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $lastRun->outcome)
|
||||||
|
: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
{{-- Operator warning banner --}}
|
||||||
<div class="text-sm font-semibold">Operator warning</div>
|
<x-filament::section>
|
||||||
<div class="mt-1 text-sm">
|
<div class="flex items-start gap-3">
|
||||||
Runbooks can modify customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
|
<x-heroicon-o-exclamation-triangle class="h-6 w-6 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
|
||||||
Rebuild Findings Lifecycle
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
</div>
|
Runbooks can modify customer data across tenants. Always run preflight first, and ensure you have the correct scope selected.
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
</p>
|
||||||
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Scope: {{ $this->scopeLabel() }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
<div class="mt-4 border-t border-gray-100 pt-4 text-sm text-gray-700 dark:border-gray-800 dark:text-gray-200">
|
{{-- Runbook card: Rebuild Findings Lifecycle --}}
|
||||||
|
<x-filament::section>
|
||||||
|
<x-slot name="heading">
|
||||||
|
Rebuild Findings Lifecycle
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<x-slot name="description">
|
||||||
|
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<x-slot name="afterHeader">
|
||||||
|
<x-filament::badge color="info" size="sm">
|
||||||
|
{{ $this->scopeLabel() }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{{-- Last run metadata --}}
|
||||||
|
@if ($lastRun)
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $lastRun->created_at?->diffForHumans() ?? '—' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if ($lastRunStatusSpec)
|
||||||
|
<x-filament::badge
|
||||||
|
:color="$lastRunStatusSpec->color"
|
||||||
|
:icon="$lastRunStatusSpec->icon"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $lastRunStatusSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($lastRunOutcomeSpec)
|
||||||
|
<x-filament::badge
|
||||||
|
:color="$lastRunOutcomeSpec->color"
|
||||||
|
:icon="$lastRunOutcomeSpec->icon"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ $lastRunOutcomeSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($lastRun->initiator_name)
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
by {{ $lastRun->initiator_name }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Preflight results --}}
|
||||||
@if (is_array($this->preflight))
|
@if (is_array($this->preflight))
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
|
<x-filament::section>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Affected</div>
|
<div class="text-center">
|
||||||
<div class="mt-1 font-semibold">{{ (int) ($this->preflight['affected_count'] ?? 0) }}</div>
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
|
||||||
</div>
|
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||||
|
{{ number_format((int) ($this->preflight['affected_count'] ?? 0)) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
|
<x-filament::section>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total scanned</div>
|
<div class="text-center">
|
||||||
<div class="mt-1 font-semibold">{{ (int) ($this->preflight['total_count'] ?? 0) }}</div>
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
|
||||||
</div>
|
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||||
|
{{ number_format((int) ($this->preflight['total_count'] ?? 0)) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
|
<x-filament::section>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Estimated tenants</div>
|
<div class="text-center">
|
||||||
<div class="mt-1 font-semibold">{{ is_numeric($this->preflight['estimated_tenants'] ?? null) ? (int) $this->preflight['estimated_tenants'] : '—' }}</div>
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
|
||||||
</div>
|
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
|
||||||
|
{{ is_numeric($this->preflight['estimated_tenants'] ?? null) ? number_format((int) $this->preflight['estimated_tenants']) : '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ((int) ($this->preflight['affected_count'] ?? 0) <= 0)
|
@if ((int) ($this->preflight['affected_count'] ?? 0) <= 0)
|
||||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
|
||||||
Nothing to do for the current scope.
|
Nothing to do for the current scope.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<div>
|
{{-- Preflight CTA --}}
|
||||||
Run <span class="font-medium">Preflight</span> to see how many findings would change for the selected scope.
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
|
||||||
|
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
||||||
|
|||||||
@ -8,104 +8,172 @@
|
|||||||
$reasonText = data_get($run->context, 'reason.reason_text');
|
$reasonText = data_get($run->context, 'reason.reason_text');
|
||||||
|
|
||||||
$platformInitiator = data_get($run->context, 'platform_initiator', []);
|
$platformInitiator = data_get($run->context, 'platform_initiator', []);
|
||||||
|
|
||||||
|
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||||
|
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||||
|
(string) $run->status,
|
||||||
|
);
|
||||||
|
|
||||||
|
$outcomeSpec = (string) $run->status === 'completed'
|
||||||
|
? \App\Support\Badges\BadgeRenderer::spec(
|
||||||
|
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||||
|
(string) $run->outcome,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$summaryCounts = $run->summary_counts;
|
||||||
|
$hasSummary = is_array($summaryCounts) && count($summaryCounts) > 0;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
{{-- Run header --}}
|
||||||
<div class="flex items-start justify-between gap-4">
|
<x-filament::section>
|
||||||
<div>
|
<x-slot name="heading">
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
Run #{{ (int) $run->getKey() }}
|
||||||
Run #{{ (int) $run->getKey() }}
|
</x-slot>
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-right text-xs text-gray-500 dark:text-gray-400">
|
<x-slot name="description">
|
||||||
<div>Started: {{ $run->started_at?->toDayDateTimeString() ?? '—' }}</div>
|
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
||||||
<div>Completed: {{ $run->completed_at?->toDayDateTimeString() ?? '—' }}</div>
|
</x-slot>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<x-slot name="afterHeader">
|
||||||
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
|
<div class="flex items-center gap-2">
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Status</div>
|
<x-filament::badge
|
||||||
<div class="mt-1 font-semibold text-gray-900 dark:text-gray-100">{{ (string) $run->status }}</div>
|
:color="$statusSpec->color"
|
||||||
</div>
|
:icon="$statusSpec->icon"
|
||||||
|
>
|
||||||
|
{{ $statusSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
|
@if ($outcomeSpec)
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Outcome</div>
|
<x-filament::badge
|
||||||
<div class="mt-1 font-semibold text-gray-900 dark:text-gray-100">{{ (string) $run->outcome }}</div>
|
:color="$outcomeSpec->color"
|
||||||
</div>
|
:icon="$outcomeSpec->icon"
|
||||||
|
>
|
||||||
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-800">
|
{{ $outcomeSpec->label }}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">Scope</div>
|
</x-filament::badge>
|
||||||
<div class="mt-1 font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
@if ($scope === 'single_tenant')
|
|
||||||
Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }}
|
|
||||||
@elseif ($scope === 'all_tenants')
|
|
||||||
All tenants
|
|
||||||
@else
|
|
||||||
{{ $scope }}
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">Initiator:</span>
|
|
||||||
{{ (string) ($run->initiator_name ?? '—') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null))
|
|
||||||
<div class="sm:text-right">
|
|
||||||
<span class="font-medium">Platform user:</span>
|
|
||||||
{{ (string) ($platformInitiator['email'] ?? '') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{{-- Key details --}}
|
||||||
|
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
@if ($scope === 'single_tenant')
|
||||||
|
<x-filament::badge color="info" size="sm">
|
||||||
|
Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@elseif ($scope === 'all_tenants')
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
All tenants
|
||||||
|
</x-filament::badge>
|
||||||
|
@else
|
||||||
|
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $scope }}</span>
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ $run->started_at?->toDayDateTimeString() ?? '—' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Completed</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ $run->completed_at?->toDayDateTimeString() ?? '—' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Initiator</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ (string) ($run->initiator_name ?? '—') }}
|
||||||
|
@if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null))
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">{{ (string) $platformInitiator['email'] }}</div>
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{{-- Reason --}}
|
||||||
@if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '')
|
@if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '')
|
||||||
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800">
|
<div class="flex items-start gap-3 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">Reason</div>
|
<x-heroicon-m-document-text class="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||||
<div class="mt-1 text-sm">
|
|
||||||
<span class="font-medium">{{ $reasonCode }}</span>
|
<div>
|
||||||
<span class="text-gray-600 dark:text-gray-300">—</span>
|
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</span>
|
||||||
<span>{{ $reasonText }}</span>
|
<div class="mt-1 text-sm text-gray-950 dark:text-white">
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ $reasonCode }}</x-filament::badge>
|
||||||
|
<span class="ml-1">{{ $reasonText }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
|
|
||||||
@if (! empty($run->summary_counts))
|
{{-- Summary counts --}}
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
@if ($hasSummary)
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary counts</div>
|
<x-filament::section>
|
||||||
<div class="mt-3">
|
<x-slot name="heading">
|
||||||
@include('filament.partials.json-viewer', ['value' => $run->summary_counts])
|
Summary counts
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
@foreach ($summaryCounts as $key => $value)
|
||||||
|
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
{{ \Illuminate\Support\Str::headline((string) $key) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xl font-bold text-gray-950 dark:text-white">
|
||||||
|
{{ is_numeric($value) ? number_format((int) $value) : $value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||||
|
Show raw JSON
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2">
|
||||||
|
@include('filament.partials.json-viewer', ['value' => $summaryCounts])
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Failures --}}
|
||||||
@if (! empty($run->failure_summary))
|
@if (! empty($run->failure_summary))
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
<x-filament::section>
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Failures</div>
|
<x-slot name="heading">
|
||||||
<div class="mt-3">
|
<div class="flex items-center gap-2 text-danger-600 dark:text-danger-400">
|
||||||
@include('filament.partials.json-viewer', ['value' => $run->failure_summary])
|
<x-heroicon-m-exclamation-circle class="h-5 w-5" />
|
||||||
</div>
|
Failures
|
||||||
</div>
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
@include('filament.partials.json-viewer', ['value' => $run->failure_summary])
|
||||||
|
</x-filament::section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
{{-- Context --}}
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Context</div>
|
<x-filament::section collapsible :collapsed="true">
|
||||||
<div class="mt-3">
|
<x-slot name="heading">
|
||||||
@include('filament.partials.json-viewer', ['value' => $run->context ?? []])
|
Context (raw)
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
|
||||||
|
@include('filament.partials.json-viewer', ['value' => $run->context ?? []])
|
||||||
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
||||||
|
|||||||
@ -157,11 +157,34 @@ ### Key Entities *(include if feature involves data)*
|
|||||||
- **Operator Notification**: A delivery record/target for failure alerts.
|
- **Operator Notification**: A delivery record/target for failure alerts.
|
||||||
- **Finding**: Tenant-owned record whose lifecycle/workflow fields may be backfilled.
|
- **Finding**: Tenant-owned record whose lifecycle/workflow fields may be backfilled.
|
||||||
|
|
||||||
|
### User Story 4 - Enterprise-grade UX polish for Ops surfaces (Priority: P2)
|
||||||
|
|
||||||
|
As a platform operator, the Ops surfaces should look and feel enterprise-grade with proper visual hierarchy, alert banners, structured card layouts, badge indicators, and metadata so I can quickly assess system state.
|
||||||
|
|
||||||
|
**Why this priority**: Operator trust and efficiency depend on clear, scannable UI. Raw text and flat layouts slow down triage.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the operator opens `/system/ops/runbooks`, **Then** the operator warning is rendered as a styled alert banner with icon (not plain text).
|
||||||
|
2. **Given** runbooks are listed, **Then** each runbook is rendered as a structured card with title, description, scope badge, and "Last run" metadata when available.
|
||||||
|
3. **Given** preflight results are displayed, **Then** stat values use consistent stat-card styling with labels and prominent values.
|
||||||
|
4. **Given** the operator opens `/system/ops/runs/{id}`, **Then** status and outcome are rendered as colored badges (consistent with the existing BadgeRenderer), and scope is shown as a badge/tag.
|
||||||
|
5. **Given** the run detail page, **Then** summary counts are rendered as a labeled grid (not only raw JSON).
|
||||||
|
|
||||||
|
### Functional Requirements (UX Polish)
|
||||||
|
|
||||||
|
- **FR-010 (Operator Warning Banner)**: The operator warning on `/system/ops/runbooks` MUST be rendered as a visually distinct alert banner with an `exclamation-triangle` icon, amber/warning coloring, and clear heading — matching project alert patterns.
|
||||||
|
- **FR-011 (Runbook Card Layout)**: Each runbook MUST be rendered as a card with: title (semibold), description, scope badge (e.g., "All tenants"), and optional "Last run" timestamp + status badge when a previous run exists.
|
||||||
|
- **FR-012 (Preflight Stat Cards)**: Preflight result values (affected, total scanned, estimated tenants) MUST be rendered in visually prominent stat cards with labeled headers.
|
||||||
|
- **FR-013 (Run Detail Badges)**: Status and outcome on run detail pages MUST use the existing `BadgeRenderer` / `BadgeCatalog` system for colored badges with icons.
|
||||||
|
- **FR-014 (Run Detail Summary Grid)**: Summary counts on run detail MUST be rendered as a labeled key-value grid, not a raw JSON dump (JSON viewer remains available as a disclosure fallback).
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
### Measurable Outcomes
|
### Measurable Outcomes
|
||||||
|
|
||||||
- **SC-001**: In production-like environments, customers have **zero** UI affordances to trigger backfills/repairs in `/admin`.
|
- **SC-001**: In production-like environments, customers have **zero** UI affordances to trigger backfills/repairs in `/admin`.
|
||||||
- **SC-002**: A platform operator can start a runbook without SSH and reach “View run” in **≤ 3 user interactions** from `/system/ops/runbooks`.
|
- **SC-002**: A platform operator can start a runbook without SSH and reach "View run" in **≤ 3 user interactions** from `/system/ops/runbooks`.
|
||||||
- **SC-003**: 100% of run attempts result in an operation run record and start/completion/failure audit events (with failure still recorded even if notifications fail).
|
- **SC-003**: 100% of run attempts result in an operation run record and start/completion/failure audit events (with failure still recorded even if notifications fail).
|
||||||
- **SC-004**: Re-running the same runbook on the same scope after completion results in `updated_count = 0` (idempotency).
|
- **SC-004**: Re-running the same runbook on the same scope after completion results in `updated_count = 0` (idempotency).
|
||||||
|
- **SC-005**: Operator warning on runbooks page renders as a styled alert banner (not plain text).
|
||||||
|
|||||||
@ -130,6 +130,21 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 7: UX Polish — Enterprise-grade Ops surfaces (User Story 4)
|
||||||
|
|
||||||
|
**Purpose**: Elevate operator-facing views from functional MVP to enterprise-grade UX with proper visual hierarchy, alert banners, card layouts, badge indicators, and metadata.
|
||||||
|
|
||||||
|
- [X] T050 [US4] Upgrade operator warning from plain text to styled alert banner with icon in resources/views/filament/system/pages/ops/runbooks.blade.php (FR-010)
|
||||||
|
- [X] T051 [US4] Restructure runbook entry as a card with title, description, scope badge, and "Last run" metadata in resources/views/filament/system/pages/ops/runbooks.blade.php + app/Filament/System/Pages/Ops/Runbooks.php (FR-011)
|
||||||
|
- [X] T052 [US4] Upgrade preflight stat values to prominent stat-card styling in resources/views/filament/system/pages/ops/runbooks.blade.php (FR-012)
|
||||||
|
- [X] T053 [US4] Render status/outcome as BadgeRenderer badges on run detail page in resources/views/filament/system/pages/ops/view-run.blade.php (FR-013)
|
||||||
|
- [X] T054 [US4] Render summary_counts as labeled key-value grid with JSON fallback on run detail in resources/views/filament/system/pages/ops/view-run.blade.php (FR-014)
|
||||||
|
- [X] T055 [US4] "Recovery" nav group with "Repair workspace owners" already exists (pre-existing; no change needed)
|
||||||
|
- [X] T056 [P] Run formatting via vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
- [X] T057 [P] Run existing Spec 113 tests to verify no regressions (16 passed, 141 assertions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
### Phase Dependencies
|
### Phase Dependencies
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
input: [
|
input: [
|
||||||
'resources/css/app.css',
|
'resources/css/app.css',
|
||||||
'resources/css/filament/admin/theme.css',
|
'resources/css/filament/admin/theme.css',
|
||||||
|
'resources/css/filament/system/theme.css',
|
||||||
'resources/js/app.js',
|
'resources/js/app.js',
|
||||||
],
|
],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user