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
- Version change: 1.8.1 → 1.8.2
- Version change: 1.8.2 → 1.9.0
- Modified principles:
- RBAC Context — Planes, Roles, and Auditability (clarified admin vs tenant-context vs workspace-context)
- 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)
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (tightened UX requirements; added layout/view/empty-state rules)
- Added sections:
- Scope & Ownership Clarification (SCOPE-001)
- Spec Scope Fields (SCOPE-002)
- Filament UI — Layout & Information Architecture Standards (UX-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md
- 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
@ -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.
- 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 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.
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.
- 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)
- Every feature spec MUST declare:
@ -245,4 +274,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **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
- 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
- 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
### 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,
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.
**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.
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 `AuditLog` entries for relevant mutations,
- 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),
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\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@ -27,9 +26,11 @@ class BaselineCompareLanding extends Page
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';
@ -50,6 +51,8 @@ class BaselineCompareLanding extends Page
/** @var array<string, int>|null */
public ?array $severityCounts = null;
public ?string $lastComparedAt = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -125,7 +128,11 @@ public function mount(): void
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()
->where('tenant_id', $tenant->getKey())
@ -207,7 +214,7 @@ private function compareNowAction(): Action
if (! ($result['ok'] ?? false)) {
Notification::make()
->title('Cannot start comparison')
->body('Reason: ' . ($result['reason_code'] ?? 'unknown'))
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
->danger()
->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|UnitEnum|null $navigationGroup = 'Drift';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Drift';

View File

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

View File

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

View File

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

View File

@ -1,93 +1,188 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Compare the current tenant state against the assigned baseline profile to detect drift.
</div>
@if (filled($profileName))
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-gray-950 dark:text-white">Baseline Profile:</span>
<span class="text-gray-700 dark:text-gray-200">{{ $profileName }}</span>
{{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{{-- Stat: Assigned Baseline --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
@if ($snapshotId)
<x-filament::badge color="success" size="sm">
<x-filament::badge color="success" size="sm" class="w-fit">
Snapshot #{{ $snapshotId }}
</x-filament::badge>
@endif
</div>
@endif
</x-filament::section>
@if ($state === 'no_tenant')
<x-filament::badge color="gray">No tenant selected</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@elseif ($state === 'no_assignment')
<x-filament::badge color="gray">No baseline assigned</x-filament::badge>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
@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
{{-- Stat: Total Findings --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
{{ $findingsCount ?? 0 }}
</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())
<x-filament::link :href="$this->getFindingsUrl()" size="sm">
View findings
{{-- Stat: Last Compared --}}
<x-filament::section>
<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>
@endif
@else
<x-filament::badge color="success" size="sm">
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>
</x-filament::section>
</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>

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-3">
<div class="text-base font-semibold text-gray-950 dark:text-white">
Soll vs Ist
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white">Baseline Governance</div>
@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>
@if (! $hasAssignment)
<div class="text-sm text-gray-500 dark:text-gray-400">
No baseline profile assigned yet.
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
Baseline: <span class="font-medium">{{ $profileName }}</span>
</div>
{{-- Findings summary --}}
@if ($findingsCount > 0)
{{-- Critical banner (inline) --}}
@if ($highCount > 0)
<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">
<x-heroicon-s-exclamation-triangle class="h-4 w-4 shrink-0 text-danger-600 dark:text-danger-400" />
<span class="text-sm font-medium text-danger-800 dark:text-danger-200">
{{ $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">
<x-filament::badge color="danger" size="sm">
{{ $findingsCount }} open {{ Str::plural('finding', $findingsCount) }}
{{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
</x-filament::badge>
@if ($highCount > 0)
<x-filament::badge color="danger" size="sm">
{{ $highCount }} high
@if ($mediumCount > 0)
<x-filament::badge color="warning" size="sm">
{{ $mediumCount }} medium
</x-filament::badge>
@endif
@if ($lowCount > 0)
<x-filament::badge color="gray" size="sm">
{{ $lowCount }} low
</x-filament::badge>
@endif
</div>
@else
<x-filament::badge color="success" size="sm">
No open drift
</x-filament::badge>
@endif
@if ($landingUrl)
<x-filament::link :href="$landingUrl" size="sm">
Go to Soll vs Ist
</x-filament::link>
@endif
</div>
@else
<div class="flex items-center gap-2 rounded-lg bg-success-50 px-3 py-2 dark:bg-success-950/50">
<x-heroicon-o-check-circle class="h-4 w-4 shrink-0 text-success-600 dark:text-success-400" />
<span class="text-sm font-medium text-success-700 dark:text-success-300">No open drift baseline compliant</span>
</div>
@endif
</div>
@endif
</div>