fix: enterprise UX overhaul for baseline governance

- Fix display_name → name column on Tenant model (3 files)
- Rename 'Soll vs Ist' to 'Baseline Compare' (English consistency)
- Redesign landing page: stats overview grid, critical drift banner,
  severity breakdown section, proper empty states with icons
- Upgrade dashboard widget: severity badges, inline critical alert,
  last compared timestamp, compliance status indicator
- Move Findings + DriftLanding from 'Drift' to 'Governance' nav group
This commit is contained in:
Ahmed Darrazi 2026-02-20 00:23:44 +01:00
parent 9085403b9b
commit a98323d2f4
12 changed files with 336 additions and 151 deletions

View File

@ -1,22 +1,19 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.8.1 → 1.8.2 - Version change: 1.8.2 → 1.9.0
- Modified principles: - Modified principles:
- RBAC Context — Planes, Roles, and Auditability (clarified admin vs tenant-context vs workspace-context) - Filament UI — Action Surface Contract (NON-NEGOTIABLE) (tightened UX requirements; added layout/view/empty-state rules)
- Tenant Isolation is Non-negotiable (added scope + ownership rules)
- RBAC-UX-007 — Global search must be tenant-safe (added workspace-context rules)
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added required spec scope fields)
- Added sections: - Added sections:
- Scope & Ownership Clarification (SCOPE-001) - Filament UI — Layout & Information Architecture Standards (UX-001)
- Spec Scope Fields (SCOPE-002)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md - ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present in this repo) - N/A: .specify/templates/commands/ (directory not present in this repo)
- Follow-up TODOs: None - Follow-up TODOs:
- Add CI regression guards for “no naked forms” + “view must use infolist” (heuristic scan) in test suite.
--> -->
# TenantPilot Constitution # TenantPilot Constitution
@ -179,7 +176,7 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column. - Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows. - Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
- View/Detail MUST define Header Actions (Edit + “More” group when applicable). - View/Detail MUST define Header Actions (Edit + “More” group when applicable).
- View/Detail SHOULD be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists. - View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
- Create/Edit MUST provide consistent Save/Cancel UX. - Create/Edit MUST provide consistent Save/Cancel UX.
Grouping & safety Grouping & safety
@ -199,6 +196,38 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason. - A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing. - CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
### Filament UI — Layout & Information Architecture Standards (UX-001)
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
Page layout
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
View pages
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
- Long text MUST render as readable prose (not textarea styling).
Empty states
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
Actions & flows
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
Table work-surface defaults
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
Enforcement
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
Spec Scope Fields (SCOPE-002) Spec Scope Fields (SCOPE-002)
- Every feature spec MUST declare: - Every feature spec MUST declare:
@ -245,4 +274,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.8.2 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-14 **Version**: 1.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-19

View File

@ -44,8 +44,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
## Project Structure ## Project Structure
### Documentation (this feature) ### Documentation (this feature)

View File

@ -115,7 +115,11 @@ ## Requirements *(mandatory)*
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, **Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied. the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
<!-- <!--
ACTION REQUIRED: The content in this section represents placeholders. ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements. Fill them out with the right functional requirements.

View File

@ -34,6 +34,14 @@ # Tasks: [FEATURE NAME]
- adding confirmations for destructive actions (and typed confirmation where required by scale), - adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations, - adding `AuditLog` entries for relevant mutations,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale. - adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
- capping header actions to max 1 primary + 1 secondary (rest grouped),
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001), **Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values. avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.

View File

@ -14,7 +14,6 @@
use App\Services\Baselines\BaselineCompareService; use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -27,9 +26,11 @@ class BaselineCompareLanding extends Page
protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Soll vs Ist'; protected static ?string $navigationLabel = 'Baseline Compare';
protected static ?int $navigationSort = 30; protected static ?int $navigationSort = 10;
protected static ?string $title = 'Baseline Compare';
protected string $view = 'filament.pages.baseline-compare-landing'; protected string $view = 'filament.pages.baseline-compare-landing';
@ -50,6 +51,8 @@ class BaselineCompareLanding extends Page
/** @var array<string, int>|null */ /** @var array<string, int>|null */
public ?array $severityCounts = null; public ?array $severityCounts = null;
public ?string $lastComparedAt = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = auth()->user(); $user = auth()->user();
@ -125,7 +128,11 @@ public function mount(): void
return; return;
} }
$scopeKey = 'baseline_profile:' . $profile->getKey(); if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
$this->lastComparedAt = $latestRun->finished_at->diffForHumans();
}
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findingsQuery = Finding::query() $findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
@ -207,7 +214,7 @@ private function compareNowAction(): Action
if (! ($result['ok'] ?? false)) { if (! ($result['ok'] ?? false)) {
Notification::make() Notification::make()
->title('Cannot start comparison') ->title('Cannot start comparison')
->body('Reason: ' . ($result['reason_code'] ?? 'unknown')) ->body('Reason: '.($result['reason_code'] ?? 'unknown'))
->danger() ->danger()
->send(); ->send();

View File

@ -28,7 +28,7 @@ class DriftLanding extends Page
{ {
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
protected static string|UnitEnum|null $navigationGroup = 'Drift'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Drift'; protected static ?string $navigationLabel = 'Drift';

View File

@ -81,7 +81,7 @@ private function captureAction(): Action
if (! $result['ok']) { if (! $result['ok']) {
Notification::make() Notification::make()
->title('Cannot start capture') ->title('Cannot start capture')
->body('Reason: ' . str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown'))) ->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
->danger() ->danger()
->send(); ->send();
@ -109,8 +109,8 @@ private function getWorkspaceTenantOptions(): array
return Tenant::query() return Tenant::query()
->where('workspace_id', $workspaceId) ->where('workspace_id', $workspaceId)
->orderBy('display_name') ->orderBy('name')
->pluck('display_name', 'id') ->pluck('name', 'id')
->all(); ->all();
} }

View File

@ -43,7 +43,7 @@ public function table(Table $table): Table
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('tenant.display_name') Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant') ->label('Tenant')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('assignedByUser.name') Tables\Columns\TextColumn::make('assignedByUser.name')
@ -178,13 +178,13 @@ private function getAvailableTenantOptions(): array
$query = Tenant::query() $query = Tenant::query()
->where('workspace_id', $profile->workspace_id) ->where('workspace_id', $profile->workspace_id)
->orderBy('display_name'); ->orderBy('name');
if (! empty($assignedTenantIds)) { if (! empty($assignedTenantIds)) {
$query->whereNotIn('id', $assignedTenantIds); $query->whereNotIn('id', $assignedTenantIds);
} }
return $query->pluck('display_name', 'id')->all(); return $query->pluck('name', 'id')->all();
} }
private function auditAssignment( private function auditAssignment(
@ -205,7 +205,7 @@ private function auditAssignment(
$auditLogger->log( $auditLogger->log(
workspace: $workspace, workspace: $workspace,
action: 'baseline.assignment.' . $action, action: 'baseline.assignment.'.$action,
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_profile_name' => (string) $profile->name, 'baseline_profile_name' => (string) $profile->name,

View File

@ -46,7 +46,7 @@ class FindingResource extends Resource
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|UnitEnum|null $navigationGroup = 'Drift'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings'; protected static ?string $navigationLabel = 'Findings';

View File

@ -4,6 +4,7 @@
namespace App\Filament\Widgets\Dashboard; namespace App\Filament\Widgets\Dashboard;
use App\Models\BaselineCompareRun;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant; use App\Models\Tenant;
@ -23,14 +24,19 @@ protected function getViewData(): array
{ {
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
$empty = [
'hasAssignment' => false,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'mediumCount' => 0,
'lowCount' => 0,
'lastComparedAt' => null,
'landingUrl' => null,
];
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return [ return $empty;
'hasAssignment' => false,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'landingUrl' => null,
];
} }
$assignment = BaselineTenantAssignment::query() $assignment = BaselineTenantAssignment::query()
@ -39,17 +45,11 @@ protected function getViewData(): array
->first(); ->first();
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) { if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
return [ return $empty;
'hasAssignment' => false,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'landingUrl' => null,
];
} }
$profile = $assignment->baselineProfile; $profile = $assignment->baselineProfile;
$scopeKey = 'baseline_profile:' . $profile->getKey(); $scopeKey = 'baseline_profile:'.$profile->getKey();
$findingsQuery = Finding::query() $findingsQuery = Finding::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
@ -62,12 +62,28 @@ protected function getViewData(): array
$highCount = (int) (clone $findingsQuery) $highCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_HIGH) ->where('severity', Finding::SEVERITY_HIGH)
->count(); ->count();
$mediumCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_MEDIUM)
->count();
$lowCount = (int) (clone $findingsQuery)
->where('severity', Finding::SEVERITY_LOW)
->count();
$latestRun = BaselineCompareRun::query()
->where('tenant_id', $tenant->getKey())
->where('baseline_profile_id', $profile->getKey())
->whereNotNull('finished_at')
->latest('finished_at')
->first();
return [ return [
'hasAssignment' => true, 'hasAssignment' => true,
'profileName' => (string) $profile->name, 'profileName' => (string) $profile->name,
'findingsCount' => $findingsCount, 'findingsCount' => $findingsCount,
'highCount' => $highCount, 'highCount' => $highCount,
'mediumCount' => $mediumCount,
'lowCount' => $lowCount,
'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(),
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant), 'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
]; ];
} }

View File

@ -1,93 +1,188 @@
<x-filament::page> <x-filament::page>
<x-filament::section> {{-- Row 1: Stats Overview --}}
<div class="flex flex-col gap-4"> @if (in_array($state, ['ready', 'idle', 'comparing']))
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
Compare the current tenant state against the assigned baseline profile to detect drift. {{-- Stat: Assigned Baseline --}}
</div> <x-filament::section>
<div class="flex flex-col gap-1">
@if (filled($profileName)) <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
<div class="flex items-center gap-2 text-sm"> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
<span class="font-medium text-gray-950 dark:text-white">Baseline Profile:</span>
<span class="text-gray-700 dark:text-gray-200">{{ $profileName }}</span>
@if ($snapshotId) @if ($snapshotId)
<x-filament::badge color="success" size="sm"> <x-filament::badge color="success" size="sm" class="w-fit">
Snapshot #{{ $snapshotId }} Snapshot #{{ $snapshotId }}
</x-filament::badge> </x-filament::badge>
@endif @endif
</div> </div>
@endif </x-filament::section>
@if ($state === 'no_tenant') {{-- Stat: Total Findings --}}
<x-filament::badge color="gray">No tenant selected</x-filament::badge> <x-filament::section>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div> <div class="flex flex-col gap-1">
@elseif ($state === 'no_assignment') <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
<x-filament::badge color="gray">No baseline assigned</x-filament::badge> <div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div> {{ $findingsCount ?? 0 }}
@elseif ($state === 'no_snapshot')
<x-filament::badge color="warning">No snapshot available</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'comparing')
<div class="flex items-center gap-2">
<x-filament::badge color="info">Comparing…</x-filament::badge>
<x-filament::loading-indicator class="h-4 w-4" />
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View operation run
</x-filament::link>
@endif
@elseif ($state === 'idle')
<x-filament::badge color="gray">Ready to compare</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'ready')
@if ($findingsCount !== null && $findingsCount > 0)
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if (($severityCounts['high'] ?? 0) > 0)
<x-filament::badge color="danger" size="sm">
{{ $severityCounts['high'] }} high
</x-filament::badge>
@endif
@if (($severityCounts['medium'] ?? 0) > 0)
<x-filament::badge color="warning" size="sm">
{{ $severityCounts['medium'] }} medium
</x-filament::badge>
@endif
@if (($severityCounts['low'] ?? 0) > 0)
<x-filament::badge color="gray" size="sm">
{{ $severityCounts['low'] }} low
</x-filament::badge>
@endif
</div> </div>
@if ($state === 'comparing')
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
<x-filament::loading-indicator class="h-3 w-3" />
Comparing…
</div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
<span class="text-sm text-success-600 dark:text-success-400">All clear</span>
@endif
</div>
</x-filament::section>
@if ($this->getFindingsUrl()) {{-- Stat: Last Compared --}}
<x-filament::link :href="$this->getFindingsUrl()" size="sm"> <x-filament::section>
View findings <div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">
{{ $lastComparedAt ?? 'Never' }}
</div>
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View run
</x-filament::link> </x-filament::link>
@endif @endif
@else </div>
<x-filament::badge color="success" size="sm"> </x-filament::section>
No drift detected
</x-filament::badge>
@if (filled($message))
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@endif
@endif
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
View last run
</x-filament::link>
@endif
@endif
</div> </div>
</x-filament::section> @endif
{{-- Critical drift banner --}}
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
<div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
Critical Drift Detected
</div>
<div class="text-sm text-danger-700 dark:text-danger-300">
The current tenant state deviates from baseline <strong>{{ $profileName }}</strong>.
{{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention.
</div>
</div>
</div>
</div>
@endif
{{-- State: No tenant / no assignment / no snapshot --}}
@if (in_array($state, ['no_tenant', 'no_assignment', 'no_snapshot']))
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
@if ($state === 'no_tenant')
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Tenant Selected</div>
@elseif ($state === 'no_assignment')
<x-heroicon-o-link-slash class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Baseline Assigned</div>
@elseif ($state === 'no_snapshot')
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Snapshot Available</div>
@endif
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
</div>
</x-filament::section>
@endif
{{-- Severity breakdown + actions --}}
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
<x-filament::section>
<x-slot name="heading">
{{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
</x-slot>
<x-slot name="description">
The tenant configuration drifted from the baseline profile.
</x-slot>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
@if (($severityCounts['high'] ?? 0) > 0)
<x-filament::badge color="danger">
{{ $severityCounts['high'] }} High
</x-filament::badge>
@endif
@if (($severityCounts['medium'] ?? 0) > 0)
<x-filament::badge color="warning">
{{ $severityCounts['medium'] }} Medium
</x-filament::badge>
@endif
@if (($severityCounts['low'] ?? 0) > 0)
<x-filament::badge color="gray">
{{ $severityCounts['low'] }} Low
</x-filament::badge>
@endif
</div>
<div class="flex items-center gap-3">
@if ($this->getFindingsUrl())
<x-filament::button
:href="$this->getFindingsUrl()"
tag="a"
color="gray"
icon="heroicon-o-eye"
size="sm"
>
View all findings
</x-filament::button>
@endif
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
Review last run
</x-filament::button>
@endif
</div>
</div>
</x-filament::section>
@endif
{{-- Ready: no drift --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Drift Detected</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
The tenant configuration matches the baseline profile. Everything looks good.
</div>
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
Review last run
</x-filament::button>
@endif
</div>
</x-filament::section>
@endif
{{-- Idle state --}}
@if ($state === 'idle')
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">Ready to Compare</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
{{ $message }}
</div>
</div>
</x-filament::section>
@endif
</x-filament::page> </x-filament::page>

View File

@ -1,41 +1,68 @@
<div class="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"> <div class="flex flex-col gap-4">
<div class="flex flex-col gap-3"> <div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white"> <div class="text-base font-semibold text-gray-950 dark:text-white">Baseline Governance</div>
Soll vs Ist @if ($landingUrl)
<x-filament::link :href="$landingUrl" size="sm" icon="heroicon-m-arrow-top-right-on-square" icon-position="after">
Details
</x-filament::link>
@endif
</div>
@if (! $hasAssignment)
<div class="flex items-start gap-3 rounded-lg bg-gray-50 p-4 dark:bg-white/5">
<x-heroicon-o-link-slash class="mt-0.5 h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" />
<div>
<div class="text-sm font-medium text-gray-950 dark:text-white">No Baseline Assigned</div>
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
</div>
</div>
@else
{{-- Profile + last compared --}}
<div class="flex items-center justify-between text-sm">
<div class="text-gray-600 dark:text-gray-300">
Baseline: <span class="font-medium text-gray-950 dark:text-white">{{ $profileName }}</span>
</div>
@if ($lastComparedAt)
<div class="text-gray-500 dark:text-gray-400">{{ $lastComparedAt }}</div>
@endif
</div> </div>
@if (! $hasAssignment) {{-- Findings summary --}}
<div class="text-sm text-gray-500 dark:text-gray-400"> @if ($findingsCount > 0)
No baseline profile assigned yet. {{-- Critical banner (inline) --}}
</div> @if ($highCount > 0)
@else <div class="flex items-center gap-2 rounded-lg border border-danger-300 bg-danger-50 px-3 py-2 dark:border-danger-700 dark:bg-danger-950/50">
<div class="text-sm text-gray-600 dark:text-gray-300"> <x-heroicon-s-exclamation-triangle class="h-4 w-4 shrink-0 text-danger-600 dark:text-danger-400" />
Baseline: <span class="font-medium">{{ $profileName }}</span> <span class="text-sm font-medium text-danger-800 dark:text-danger-200">
</div> {{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }}
</span>
</div>
@endif
@if ($findingsCount > 0) <div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="danger" size="sm"> <x-filament::badge color="danger" size="sm">
{{ $findingsCount }} open {{ Str::plural('finding', $findingsCount) }} {{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge> </x-filament::badge>
@if ($highCount > 0) @if ($mediumCount > 0)
<x-filament::badge color="danger" size="sm"> <x-filament::badge color="warning" size="sm">
{{ $highCount }} high {{ $mediumCount }} medium
</x-filament::badge>
@endif
@if ($lowCount > 0)
<x-filament::badge color="gray" size="sm">
{{ $lowCount }} low
</x-filament::badge> </x-filament::badge>
@endif @endif
</div> </div>
@else </div>
<x-filament::badge color="success" size="sm"> @else
No open drift <div class="flex items-center gap-2 rounded-lg bg-success-50 px-3 py-2 dark:bg-success-950/50">
</x-filament::badge> <x-heroicon-o-check-circle class="h-4 w-4 shrink-0 text-success-600 dark:text-success-400" />
@endif <span class="text-sm font-medium text-success-700 dark:text-success-300">No open drift baseline compliant</span>
</div>
@if ($landingUrl)
<x-filament::link :href="$landingUrl" size="sm">
Go to Soll vs Ist
</x-filament::link>
@endif
@endif @endif
</div> @endif
</div> </div>