feat: centralize status-like badge semantics

This commit is contained in:
Ahmed Darrazi 2026-01-23 00:40:52 +01:00
parent e1ed7ae232
commit ffd41693f8
64 changed files with 2296 additions and 233 deletions

View File

@ -3,6 +3,8 @@
namespace App\Filament\Pages\Monitoring; namespace App\Filament\Pages\Monitoring;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use BackedEnum; use BackedEnum;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -51,21 +53,17 @@ public function table(Table $table): Table
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->colors([ ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
'secondary' => 'queued', ->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
'warning' => 'running', ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
'success' => 'completed', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
]),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->colors([ ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
'gray' => 'pending', ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
'success' => 'succeeded', ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
'warning' => 'partially_succeeded', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
'danger' => 'failed',
'secondary' => 'cancelled',
]),
TextColumn::make('initiator_name') TextColumn::make('initiator_name')
->label('Initiator') ->label('Initiator')

View File

@ -15,6 +15,8 @@
use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -215,23 +217,35 @@ public static function table(Table $table): Table
TextColumn::make('last_run_status') TextColumn::make('last_run_status')
->label('Last run status') ->label('Last run status')
->badge() ->badge()
->formatStateUsing(fn (?string $state): string => match ($state) { ->formatStateUsing(function (?string $state): string {
BackupScheduleRun::STATUS_RUNNING => 'Running', if (! filled($state)) {
BackupScheduleRun::STATUS_SUCCESS => 'Success', return '—';
BackupScheduleRun::STATUS_PARTIAL => 'Partial', }
BackupScheduleRun::STATUS_FAILED => 'Failed',
BackupScheduleRun::STATUS_CANCELED => 'Canceled', return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label;
BackupScheduleRun::STATUS_SKIPPED => 'Skipped',
default => $state ? Str::headline($state) : '—',
}) })
->color(fn (?string $state): string => match ($state) { ->color(function (?string $state): string {
BackupScheduleRun::STATUS_SUCCESS => 'success', if (! filled($state)) {
BackupScheduleRun::STATUS_PARTIAL => 'warning', return 'gray';
BackupScheduleRun::STATUS_RUNNING => 'primary', }
BackupScheduleRun::STATUS_SKIPPED => 'gray',
BackupScheduleRun::STATUS_FAILED, return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color;
BackupScheduleRun::STATUS_CANCELED => 'danger', })
default => 'gray', ->icon(function (?string $state): ?string {
if (! filled($state)) {
return null;
}
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon;
})
->iconColor(function (?string $state): string {
if (! filled($state)) {
return 'gray';
}
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state);
return $spec->iconColor ?? $spec->color;
}), }),
TextColumn::make('last_run_at') TextColumn::make('last_run_at')

View File

@ -5,6 +5,8 @@
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Models\BackupScheduleRun; use App\Models\BackupScheduleRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
@ -27,15 +29,10 @@ public function table(Table $table): Table
->dateTime(), ->dateTime(),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->color(fn (?string $state): string => match ($state) { ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
BackupScheduleRun::STATUS_SUCCESS => 'success', ->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
BackupScheduleRun::STATUS_PARTIAL => 'warning', ->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
BackupScheduleRun::STATUS_RUNNING => 'primary', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
BackupScheduleRun::STATUS_SKIPPED => 'gray',
BackupScheduleRun::STATUS_FAILED,
BackupScheduleRun::STATUS_CANCELED => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('duration') Tables\Columns\TextColumn::make('duration')
->label('Duration') ->label('Duration')
->getStateUsing(function (BackupScheduleRun $record): string { ->getStateUsing(function (BackupScheduleRun $record): string {

View File

@ -14,6 +14,8 @@
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum; use BackedEnum;
@ -57,7 +59,12 @@ public static function table(Table $table): Table
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('name')->searchable(), Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('status')->badge(), Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items'), Tables\Columns\TextColumn::make('item_count')->label('Items'),
Tables\Columns\TextColumn::make('created_by')->label('Created by'), Tables\Columns\TextColumn::make('created_by')->label('Created by'),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
@ -401,7 +408,12 @@ public static function infolist(Schema $schema): Schema
return $schema return $schema
->schema([ ->schema([
Infolists\Components\TextEntry::make('name'), Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('status')->badge(), Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Infolists\Components\TextEntry::make('item_count')->label('Items'), Infolists\Components\TextEntry::make('item_count')->label('Items'),
Infolists\Components\TextEntry::make('created_by')->label('Created by'), Infolists\Components\TextEntry::make('created_by')->label('Created by'),
Infolists\Components\TextEntry::make('completed_at')->dateTime(), Infolists\Components\TextEntry::make('completed_at')->dateTime(),

View File

@ -8,6 +8,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -56,12 +58,18 @@ public function table(Table $table): Table
->label('Restore') ->label('Restore')
->badge() ->badge()
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled') ->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->color(fn (?string $state) => $state === 'preview-only' ? 'warning' : 'success'), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
Tables\Columns\TextColumn::make('risk') Tables\Columns\TextColumn::make('risk')
->label('Risk') ->label('Risk')
->badge() ->badge()
->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a') ->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a')
->color(fn (?string $state) => str_contains((string) $state, 'high') ? 'danger' : 'gray'), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
Tables\Columns\TextColumn::make('policy_identifier') Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID') ->label('Policy ID')
->copyable(), ->copyable(),

View File

@ -5,6 +5,8 @@
use App\Filament\Resources\EntraGroupSyncRunResource\Pages; use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Models\EntraGroupSyncRun; use App\Models\EntraGroupSyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -60,7 +62,10 @@ public static function infolist(Schema $schema): Schema
->placeholder('—'), ->placeholder('—'),
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
TextEntry::make('selection_key')->label('Selection'), TextEntry::make('selection_key')->label('Selection'),
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(), TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
TextEntry::make('started_at')->dateTime(), TextEntry::make('started_at')->dateTime(),
@ -106,7 +111,10 @@ public static function table(Table $table): Table
->toggleable(), ->toggleable(),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
Tables\Columns\TextColumn::make('selection_key') Tables\Columns\TextColumn::make('selection_key')
->label('Selection') ->label('Selection')
->limit(24) ->limit(24)
@ -143,16 +151,4 @@ public static function getPages(): array
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'), 'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
]; ];
} }
private static function statusColor(?string $status): string
{
return match ($status) {
EntraGroupSyncRun::STATUS_SUCCEEDED => 'success',
EntraGroupSyncRun::STATUS_PARTIAL => 'warning',
EntraGroupSyncRun::STATUS_FAILED => 'danger',
EntraGroupSyncRun::STATUS_RUNNING => 'info',
EntraGroupSyncRun::STATUS_PENDING => 'gray',
default => 'gray',
};
}
} }

View File

@ -9,6 +9,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder; use App\Services\Drift\DriftFindingDiffBuilder;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
@ -50,8 +52,18 @@ public static function infolist(Schema $schema): Schema
Section::make('Finding') Section::make('Finding')
->schema([ ->schema([
TextEntry::make('finding_type')->badge()->label('Type'), TextEntry::make('finding_type')->badge()->label('Type'),
TextEntry::make('status')->badge(), TextEntry::make('status')
TextEntry::make('severity')->badge(), ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextEntry::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(), TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
TextEntry::make('scope_key')->label('Scope')->copyable(), TextEntry::make('scope_key')->label('Scope')->copyable(),
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
@ -188,8 +200,18 @@ public static function table(Table $table): Table
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->columns([ ->columns([
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'), Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
Tables\Columns\TextColumn::make('status')->badge(), Tables\Columns\TextColumn::make('status')
Tables\Columns\TextColumn::make('severity')->badge(), ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
Tables\Columns\TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'), Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(), Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),

View File

@ -6,6 +6,8 @@
use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -66,14 +68,23 @@ public static function infolist(Schema $schema): Schema
->placeholder('—'), ->placeholder('—'),
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
TextEntry::make('selection_hash')->label('Selection hash')->copyable(), TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
TextEntry::make('started_at')->dateTime(), TextEntry::make('started_at')->dateTime(),
TextEntry::make('finished_at')->dateTime(), TextEntry::make('finished_at')->dateTime(),
TextEntry::make('items_observed_count')->label('Observed')->numeric(), TextEntry::make('items_observed_count')->label('Observed')->numeric(),
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
TextEntry::make('errors_count')->label('Errors')->numeric(), TextEntry::make('errors_count')->label('Errors')->numeric(),
TextEntry::make('had_errors')->label('Had errors')->badge(), TextEntry::make('had_errors')
->label('Had errors')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors))
->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
@ -116,7 +127,10 @@ public static function table(Table $table): Table
->toggleable(), ->toggleable(),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->color(fn (InventorySyncRun $record): string => static::statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
Tables\Columns\TextColumn::make('selection_hash') Tables\Columns\TextColumn::make('selection_hash')
->label('Selection') ->label('Selection')
->copyable() ->copyable()
@ -155,16 +169,4 @@ public static function getPages(): array
'view' => Pages\ViewInventorySyncRun::route('/{record}'), 'view' => Pages\ViewInventorySyncRun::route('/{record}'),
]; ];
} }
private static function statusColor(?string $status): string
{
return match ($status) {
InventorySyncRun::STATUS_SUCCESS => 'success',
InventorySyncRun::STATUS_PARTIAL => 'warning',
InventorySyncRun::STATUS_FAILED => 'danger',
InventorySyncRun::STATUS_SKIPPED => 'gray',
InventorySyncRun::STATUS_RUNNING => 'info',
default => 'gray',
};
}
} }

View File

@ -5,6 +5,8 @@
use App\Filament\Resources\OperationRunResource\Pages; use App\Filament\Resources\OperationRunResource\Pages;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -63,10 +65,16 @@ public static function infolist(Schema $schema): Schema
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)), ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
->color(fn (OperationRun $record): string => static::statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextEntry::make('outcome') TextEntry::make('outcome')
->badge() ->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextEntry::make('initiator_name')->label('Initiator'), TextEntry::make('initiator_name')->label('Initiator'),
TextEntry::make('target_scope_display') TextEntry::make('target_scope_display')
->label('Target') ->label('Target')
@ -147,7 +155,10 @@ public static function table(Table $table): Table
->columns([ ->columns([
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->color(fn (OperationRun $record): string => static::statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
@ -170,7 +181,10 @@ public static function table(Table $table): Table
}), }),
Tables\Columns\TextColumn::make('outcome') Tables\Columns\TextColumn::make('outcome')
->badge() ->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('type') Tables\Filters\SelectFilter::make('type')
@ -256,27 +270,6 @@ public static function getPages(): array
]; ];
} }
private static function statusColor(?string $status): string
{
return match ($status) {
'queued' => 'secondary',
'running' => 'warning',
'completed' => 'success',
default => 'gray',
};
}
private static function outcomeColor(?string $outcome): string
{
return match ($outcome) {
'succeeded' => 'success',
'partially_succeeded' => 'warning',
'failed' => 'danger',
'cancelled' => 'gray',
default => 'gray',
};
}
private static function targetScopeDisplay(OperationRun $record): ?string private static function targetScopeDisplay(OperationRun $record): ?string
{ {
$context = is_array($record->context) ? $record->context : []; $context = is_array($record->context) ? $record->context : [];

View File

@ -14,6 +14,8 @@
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -66,8 +68,11 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('latest_snapshot_mode') TextEntry::make('latest_snapshot_mode')
->label('Snapshot') ->label('Snapshot')
->badge() ->badge()
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success') ->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full') ->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode))
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata_only' : 'full')
->helperText(function (Policy $record): ?string { ->helperText(function (Policy $record): ?string {
$meta = static::latestVersionMetadata($record); $meta = static::latestVersionMetadata($record);

View File

@ -21,6 +21,8 @@
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -709,7 +711,12 @@ public static function table(Table $table): Table
->columns([ ->columns([
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(), Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
Tables\Columns\TextColumn::make('status')->badge(), Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
Tables\Columns\TextColumn::make('summary_total') Tables\Columns\TextColumn::make('summary_total')
->label('Total') ->label('Total')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
@ -1251,7 +1258,12 @@ public static function infolist(Schema $schema): Schema
return $schema return $schema
->schema([ ->schema([
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'), Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
Infolists\Components\TextEntry::make('status')->badge(), Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)),
Infolists\Components\TextEntry::make('counts') Infolists\Components\TextEntry::make('counts')
->label('Counts') ->label('Counts')
->state(function (RestoreRun $record): string { ->state(function (RestoreRun $record): string {

View File

@ -17,6 +17,8 @@
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -149,9 +151,17 @@ public static function table(Table $table): Table
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('app_status') Tables\Columns\TextColumn::make('app_status')
->badge(), ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->since(), ->since(),
@ -501,35 +511,26 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(), Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
Infolists\Components\TextEntry::make('status') Infolists\Components\TextEntry::make('status')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
'active' => 'success', ->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
'inactive' => 'gray', ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
'suspended' => 'warning', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
'error' => 'danger',
default => 'gray',
}),
Infolists\Components\TextEntry::make('app_status') Infolists\Components\TextEntry::make('app_status')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
'ok', 'configured' => 'success', ->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
'pending' => 'warning', ->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
'error' => 'danger', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
'requires_consent' => 'warning',
default => 'gray',
}),
Infolists\Components\TextEntry::make('app_notes')->label('Notes'), Infolists\Components\TextEntry::make('app_notes')->label('Notes'),
Infolists\Components\TextEntry::make('created_at')->dateTime(), Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(), Infolists\Components\TextEntry::make('updated_at')->dateTime(),
Infolists\Components\TextEntry::make('rbac_status') Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status') ->label('RBAC status')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
'ok', 'configured' => 'success', ->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
'manual_assignment_required' => 'warning', ->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
'error', 'failed' => 'danger', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus)),
'not_configured' => 'gray',
default => 'gray',
}),
Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'), Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(), Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'), Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
@ -558,12 +559,10 @@ public static function infolist(Schema $schema): Schema
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state), ->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('status') Infolists\Components\TextEntry::make('status')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
'granted' => 'success', ->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
'missing' => 'warning', ->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
'error' => 'danger', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
default => 'gray',
}),
]) ])
->columnSpanFull(), ->columnSpanFull(),
]); ]);

View File

@ -8,6 +8,8 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -41,14 +43,16 @@ public function table(Table $table): Table
->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null), ->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null),
TextColumn::make('severity') TextColumn::make('severity')
->badge() ->badge()
->color(fn (Finding $record): string => match ($record->severity) { ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
Finding::SEVERITY_HIGH => 'danger', ->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
Finding::SEVERITY_MEDIUM => 'warning', ->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
default => 'gray', ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
}),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->color(fn (Finding $record): string => $record->status === Finding::STATUS_NEW ? 'warning' : 'gray'), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Created') ->label('Created')
->since(), ->since(),

View File

@ -6,6 +6,8 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
@ -43,10 +45,16 @@ public function table(Table $table): Table
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)), ->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->color(fn (OperationRun $record): string => $this->statusColor($record->status)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->color(fn (OperationRun $record): string => $this->outcomeColor($record->outcome)), ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Started') ->label('Started')
->since(), ->since(),
@ -70,25 +78,4 @@ private function getQuery(): Builder
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('created_at'); ->latest('created_at');
} }
private function statusColor(?string $status): string
{
return match ($status) {
'queued' => 'secondary',
'running' => 'warning',
'completed' => 'success',
default => 'gray',
};
}
private function outcomeColor(?string $outcome): string
{
return match ($outcome) {
'succeeded' => 'success',
'partially_succeeded' => 'warning',
'failed' => 'danger',
'cancelled' => 'gray',
default => 'gray',
};
}
} }

View File

@ -9,6 +9,8 @@
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -107,8 +109,10 @@ public function table(Table $table): Table
TextColumn::make('ignored_at') TextColumn::make('ignored_at')
->label('Ignored') ->label('Ignored')
->badge() ->badge()
->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray') ->formatStateUsing(BadgeRenderer::label(BadgeDomain::IgnoredAt))
->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no') ->color(BadgeRenderer::color(BadgeDomain::IgnoredAt))
->icon(BadgeRenderer::icon(BadgeDomain::IgnoredAt))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::IgnoredAt))
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions')) ->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions'))

View File

@ -0,0 +1,122 @@
<?php
namespace App\Support\Badges;
use BackedEnum;
use Stringable;
use Throwable;
final class BadgeCatalog
{
/**
* @var array<string, class-string<BadgeMapper>>
*/
private const DOMAIN_MAPPERS = [
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::InventorySyncRunStatus->value => Domains\InventorySyncRunStatusBadge::class,
BadgeDomain::BackupScheduleRunStatus->value => Domains\BackupScheduleRunStatusBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::EntraGroupSyncRunStatus->value => Domains\EntraGroupSyncRunStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,
BadgeDomain::FindingSeverity->value => Domains\FindingSeverityBadge::class,
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
];
/**
* @var array<string, BadgeMapper|null>
*/
private static array $mapperCache = [];
public static function spec(BadgeDomain $domain, mixed $value): BadgeSpec
{
$mapper = self::mapper($domain);
if (! $mapper) {
return BadgeSpec::unknown();
}
try {
return $mapper->spec($value);
} catch (Throwable) {
return BadgeSpec::unknown();
}
}
public static function mapper(BadgeDomain $domain): ?BadgeMapper
{
$key = $domain->value;
if (array_key_exists($key, self::$mapperCache)) {
return self::$mapperCache[$key];
}
$mapper = self::buildMapper($domain);
self::$mapperCache[$key] = $mapper;
return $mapper;
}
public static function normalizeState(mixed $value): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if ($value instanceof Stringable) {
$value = (string) $value;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (! is_string($value)) {
return null;
}
$normalized = strtolower(trim($value));
$normalized = str_replace([' ', '-'], '_', $normalized);
return $normalized === '' ? null : $normalized;
}
private static function buildMapper(BadgeDomain $domain): ?BadgeMapper
{
$mapperClass = self::DOMAIN_MAPPERS[$domain->value] ?? null;
if (! $mapperClass) {
return null;
}
if (! class_exists($mapperClass)) {
return null;
}
$mapper = new $mapperClass;
return $mapper instanceof BadgeMapper ? $mapper : null;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Support\Badges;
enum BadgeDomain: string
{
case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome';
case InventorySyncRunStatus = 'inventory_sync_run_status';
case BackupScheduleRunStatus = 'backup_schedule_run_status';
case BackupSetStatus = 'backup_set_status';
case EntraGroupSyncRunStatus = 'entra_group_sync_run_status';
case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity';
case FindingStatus = 'finding_status';
case FindingSeverity = 'finding_severity';
case BooleanEnabled = 'boolean_enabled';
case BooleanHasErrors = 'boolean_has_errors';
case TenantStatus = 'tenant_status';
case TenantAppStatus = 'tenant_app_status';
case TenantRbacStatus = 'tenant_rbac_status';
case TenantPermissionStatus = 'tenant_permission_status';
case PolicySnapshotMode = 'policy_snapshot_mode';
case PolicyRestoreMode = 'policy_restore_mode';
case PolicyRisk = 'policy_risk';
case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status';
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Support\Badges;
interface BadgeMapper
{
public function spec(mixed $value): BadgeSpec;
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Support\Badges;
use Closure;
final class BadgeRenderer
{
public static function label(BadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): string {
return BadgeCatalog::spec($domain, $state)->label;
};
}
public static function color(BadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): string {
return BadgeCatalog::spec($domain, $state)->color;
};
}
public static function icon(BadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): ?string {
return BadgeCatalog::spec($domain, $state)->icon;
};
}
public static function iconColor(BadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): ?string {
$spec = BadgeCatalog::spec($domain, $state);
return $spec->iconColor ?? $spec->color;
};
}
public static function spec(BadgeDomain $domain, mixed $state): BadgeSpec
{
return BadgeCatalog::spec($domain, $state);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Support\Badges;
use InvalidArgumentException;
final class BadgeSpec
{
/**
* @var array<int, string>
*/
private const ALLOWED_COLORS = [
'gray',
'info',
'success',
'warning',
'danger',
'primary',
];
public function __construct(
public readonly string $label,
public readonly string $color,
public readonly ?string $icon = null,
public readonly ?string $iconColor = null,
) {
if (trim($this->label) === '') {
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
}
if (! in_array($this->color, self::ALLOWED_COLORS, true)) {
throw new InvalidArgumentException('BadgeSpec color must be one of: '.implode(', ', self::ALLOWED_COLORS));
}
if ($this->icon !== null && trim($this->icon) === '') {
throw new InvalidArgumentException('BadgeSpec icon must be null or a non-empty string.');
}
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
}
}
/**
* @return array<int, string>
*/
public static function allowedColors(): array
{
return self::ALLOWED_COLORS;
}
public static function unknown(): self
{
return new self(
label: 'Unknown',
color: 'gray',
icon: 'heroicon-m-question-mark-circle',
iconColor: 'gray',
);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\BackupScheduleRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BackupScheduleRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
BackupScheduleRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
BackupScheduleRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
BackupScheduleRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
BackupScheduleRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
BackupScheduleRun::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-minus-circle'),
BackupScheduleRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BackupSetStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'running' => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
'completed' => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BooleanEnabledBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'true', '1', 'yes', 'enabled' => new BadgeSpec('Enabled', 'success', 'heroicon-m-check-circle'),
'false', '0', 'no', 'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BooleanHasErrorsBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'true', '1', 'yes', 'errors' => new BadgeSpec('Errors', 'danger', 'heroicon-m-x-circle'),
'false', '0', 'no' => new BadgeSpec('No errors', 'success', 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\EntraGroupSyncRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class EntraGroupSyncRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
EntraGroupSyncRun::STATUS_PENDING => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
EntraGroupSyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
EntraGroupSyncRun::STATUS_SUCCEEDED => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
EntraGroupSyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
EntraGroupSyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\Finding;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class FindingSeverityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
Finding::SEVERITY_LOW => new BadgeSpec('Low', 'gray', 'heroicon-m-minus-circle'),
Finding::SEVERITY_MEDIUM => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
Finding::SEVERITY_HIGH => new BadgeSpec('High', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\Finding;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class FindingStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
Finding::STATUS_NEW => new BadgeSpec('New', 'warning', 'heroicon-m-clock'),
Finding::STATUS_ACKNOWLEDGED => new BadgeSpec('Acknowledged', 'gray', 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class IgnoredAtBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
if ($state === null) {
return new BadgeSpec('No', 'gray', 'heroicon-m-minus-circle');
}
return new BadgeSpec('Yes', 'warning', 'heroicon-m-exclamation-triangle');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Support\Badges\Domains;
use App\Models\InventorySyncRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class InventorySyncRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
InventorySyncRun::STATUS_PENDING, 'queued' => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
InventorySyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
InventorySyncRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
InventorySyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
InventorySyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
InventorySyncRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\OperationRunOutcome;
final class OperationRunOutcomeBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\OperationRunStatus;
final class OperationRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class PolicyRestoreModeBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'enabled' => new BadgeSpec('Enabled', 'success', 'heroicon-m-check-circle'),
'preview_only' => new BadgeSpec('Preview only', 'warning', 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class PolicyRiskBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'low' => new BadgeSpec('Low', 'gray', 'heroicon-m-minus-circle'),
'low_medium' => new BadgeSpec('Low-medium', 'warning', 'heroicon-m-exclamation-triangle'),
'medium' => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
'medium_high' => new BadgeSpec('Medium-high', 'danger', 'heroicon-m-x-circle'),
'high' => new BadgeSpec('High', 'danger', 'heroicon-m-x-circle'),
'n/a' => new BadgeSpec('N/A', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class PolicySnapshotModeBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'full' => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
'metadata_only' => new BadgeSpec('Metadata only', 'warning', 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class RestoreCheckSeverityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'),
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class RestorePreviewDecisionBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'created' => new BadgeSpec('Created', 'success', 'heroicon-m-check-circle'),
'created_copy' => new BadgeSpec('Created copy', 'warning', 'heroicon-m-exclamation-triangle'),
'mapped_existing' => new BadgeSpec('Mapped existing', 'info', 'heroicon-m-arrow-path'),
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class RestoreResultStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'),
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'),
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'),
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\RestoreRunStatus;
final class RestoreRunStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'),
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'),
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'),
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'),
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'),
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantAppStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantPermissionStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
'missing' => new BadgeSpec('Missing', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantRbacStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
'manual_assignment_required' => new BadgeSpec('Manual assignment required', 'warning', 'heroicon-m-exclamation-triangle'),
'not_configured' => new BadgeSpec('Not configured', 'gray', 'heroicon-m-minus-circle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'active' => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
'inactive' => new BadgeSpec('Inactive', 'gray', 'heroicon-m-minus-circle'),
'archived' => new BadgeSpec('Archived', 'gray', 'heroicon-m-minus-circle'),
'suspended' => new BadgeSpec('Suspended', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -22,12 +22,8 @@
} }
} }
$severityColor = static function (?string $severity): string { $severitySpec = static function (?string $severity): \App\Support\Badges\BadgeSpec {
return match ($severity) { return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity);
'blocking' => 'danger',
'warning' => 'warning',
default => 'success',
};
}; };
$limitedList = static function (array $items, int $limit = 5): array { $limitedList = static function (array $items, int $limit = 5): array {
@ -91,8 +87,12 @@
@endif @endif
</div> </div>
<x-filament::badge :color="$severityColor($severity)" size="sm"> @php
{{ ucfirst((string) $severity) }} $spec = $severitySpec($severity);
@endphp
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
{{ $spec->label }}
</x-filament::badge> </x-filament::badge>
</div> </div>

View File

@ -18,21 +18,14 @@
@foreach ($foundationItems as $item) @foreach ($foundationItems as $item)
@php @php
$decision = $item['decision'] ?? 'mapped_existing'; $decision = $item['decision'] ?? 'mapped_existing';
$decisionColor = match ($decision) { $decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
'created' => 'text-green-700 bg-green-100 border-green-200',
'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200',
'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200',
'failed' => 'text-red-700 bg-red-100 border-red-200',
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp @endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm"> <div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800"> <div class="flex items-center justify-between text-sm text-gray-800">
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span> <span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $decisionColor }}"> <x-filament::badge :color="$decisionSpec->color" :icon="$decisionSpec->icon" size="sm">
{{ $decision }} {{ $decisionSpec->label }}
</span> </x-filament::badge>
</div> </div>
<div class="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600">
{{ $item['type'] ?? 'foundation' }} {{ $item['type'] ?? 'foundation' }}
@ -61,13 +54,16 @@
@endphp @endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm"> <div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800"> <div class="flex items-center justify-between text-sm text-gray-800">
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span> <span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only') @if ($restoreMode === 'preview-only')
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900"> @php
preview-only $restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode);
</span> @endphp
@endif <x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm">
{{ $restoreModeSpec->label }}
</x-filament::badge>
@endif
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700"> <span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
{{ $item['action'] ?? 'action' }} {{ $item['action'] ?? 'action' }}
</span> </span>

View File

@ -53,21 +53,14 @@
@foreach ($foundationItems as $item) @foreach ($foundationItems as $item)
@php @php
$decision = $item['decision'] ?? 'mapped_existing'; $decision = $item['decision'] ?? 'mapped_existing';
$decisionColor = match ($decision) { $decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
'created' => 'text-green-700 bg-green-100 border-green-200',
'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200',
'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200',
'failed' => 'text-red-700 bg-red-100 border-red-200',
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp @endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm"> <div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800"> <div class="flex items-center justify-between text-sm text-gray-800">
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span> <span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $decisionColor }}"> <x-filament::badge :color="$decisionSpec->color" :icon="$decisionSpec->icon" size="sm">
{{ $decision }} {{ $decisionSpec->label }}
</span> </x-filament::badge>
</div> </div>
<div class="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600">
{{ $item['type'] ?? 'foundation' }} {{ $item['type'] ?? 'foundation' }}
@ -106,25 +99,20 @@
@php @php
$status = $item['status'] ?? 'unknown'; $status = $item['status'] ?? 'unknown';
$restoreMode = $item['restore_mode'] ?? null; $restoreMode = $item['restore_mode'] ?? null;
$statusColor = match ($status) { $statusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $status);
'applied' => 'text-green-700 bg-green-100 border-green-200',
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200',
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
'failed' => 'text-red-700 bg-red-100 border-red-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp @endphp
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only') @if ($restoreMode === 'preview-only')
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900"> @php
preview-only $restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode);
</span> @endphp
@endif <x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm">
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}"> {{ $restoreModeSpec->label }}
{{ $status }} </x-filament::badge>
</span> @endif
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div> </div>
</div> </div>
@ -165,11 +153,7 @@
@foreach ($assignmentIssues as $outcome) @foreach ($assignmentIssues as $outcome)
@php @php
$outcomeStatus = $outcome['status'] ?? 'unknown'; $outcomeStatus = $outcome['status'] ?? 'unknown';
$outcomeColor = match ($outcomeStatus) { $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $outcomeStatus);
'failed' => 'text-red-700 bg-red-100 border-red-200',
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
$assignmentGroupId = $outcome['group_id'] $assignmentGroupId = $outcome['group_id']
?? ($outcome['assignment']['target']['groupId'] ?? null); ?? ($outcome['assignment']['target']['groupId'] ?? null);
$assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null); $assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null);
@ -182,9 +166,9 @@
<div class="font-semibold text-gray-900"> <div class="font-semibold text-gray-900">
Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }} Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
</div> </div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}"> <x-filament::badge :color="$outcomeSpec->color" :icon="$outcomeSpec->icon" size="sm">
{{ $outcomeStatus }} {{ $outcomeSpec->label }}
</span> </x-filament::badge>
</div> </div>
@if (! empty($outcome['mapped_group_id'])) @if (! empty($outcome['mapped_group_id']))
@ -240,20 +224,16 @@
@foreach ($complianceEntries as $outcome) @foreach ($complianceEntries as $outcome)
@php @php
$outcomeStatus = $outcome['status'] ?? 'unknown'; $outcomeStatus = $outcome['status'] ?? 'unknown';
$outcomeColor = match ($outcomeStatus) { $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $outcomeStatus);
'mapped' => 'text-green-700 bg-green-100 border-green-200',
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp @endphp
<div class="rounded border border-amber-200 bg-white p-2"> <div class="rounded border border-amber-200 bg-white p-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold text-gray-900"> <div class="font-semibold text-gray-900">
Template {{ $outcome['template_id'] ?? 'unknown' }} Template {{ $outcome['template_id'] ?? 'unknown' }}
</div> </div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}"> <x-filament::badge :color="$outcomeSpec->color" :icon="$outcomeSpec->icon" size="sm">
{{ $outcomeStatus }} {{ $outcomeSpec->label }}
</span> </x-filament::badge>
</div> </div>
@if (! empty($outcome['rule_name'])) @if (! empty($outcome['rule_name']))
<div class="mt-1 text-[11px] text-gray-700"> <div class="mt-1 text-[11px] text-gray-700">

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Unified Badge System (Single Source of Truth) v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-22
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation result: PASS
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@ -0,0 +1,42 @@
# Badge Semantics Contract — Unified Badge System (v1)
This feature does not introduce HTTP APIs. These contracts define stable UI semantics for status-like badges across the admin panel.
## Scope (v1)
### In scope (status-like)
- Status/health signals (for example: queued/running/completed; enabled/disabled; available/missing; acknowledged/new).
- Severity/risk signals (for example: drift finding severity).
### Out of scope (v1)
- Tag/category chips (for example: policy type, platform, environment labels).
## Canonical color meanings (v1)
- `success`: successful completion / safe / positive terminal meaning.
- `warning`: queued / needs attention / partial / caution.
- `info`: actively running / in progress.
- `danger`: failed / blocking / high risk.
- `gray`: neutral, unknown, or “not applicable”.
## Invariants (v1)
- Success/completed outcomes must never render as warning/attention.
- Unknown values must render safely (neutral) and must not be misrepresented as success.
- Badge rendering must be tenant-safe and must not trigger outbound HTTP, queued work, or DB lookups beyond already-loaded data.
## Canonical mappings (v1)
### Drift finding severity
- `low` → neutral (`gray`)
- `medium` → caution (`warning`)
- `high` → high risk (`danger`)
### Run-like states (minimum)
Run-like states must consistently convey:
- queued → caution (`warning`)
- running → in progress (`info`)
- succeeded/completed → success (`success`)
- partial → caution (`warning`)
- failed → failure (`danger`)

View File

@ -0,0 +1,29 @@
# Guardrails Contract — Unified Badge System (v1)
This feature enforces a single source of truth for status-like badge semantics (status/health and severity/risk).
## What is enforced (v1)
- Status-like badges must not define meaning ad-hoc per page/resource/widget/view.
- Central semantics must be used for status/health and severity/risk signals.
Tag/category chips are out of scope for v1 and are not blocked by these guardrails.
## Guard strategy (v1)
### Mapping tests
- Each status-like badge domain has automated tests that assert canonical mappings and invariants.
- Invariant example: success/completed must never be represented with warning/attention meaning.
### Lightweight repo guard
- A test scans the codebase and flags newly introduced ad-hoc status-like badge semantics patterns.
- The guard is intentionally lightweight (pattern-based) to keep noise low while preventing regression.
## Examples of patterns the guard should flag
- Ad-hoc status/severity `match` blocks inside badge rendering logic for status-like fields.
- Per-resource status badge color maps defined inline on list pages/widgets.
- Custom Blade badge markup for status-like semantics when the central system exists.
The exact patterns are defined by the guard test implementation and may be refined to reduce false positives.

View File

@ -0,0 +1,66 @@
# Data Model — Unified Badge System (Single Source of Truth) v1
This feature is UI-only. It introduces no database schema changes.
## Entities (existing)
### Tenant
- **Role**: scope boundary for all queries and UI rendering.
- **Status-like fields used**:
- `status` (for example, `active`, `archived`)
- `is_current` (boolean)
### OperationRun
- **Role**: canonical operations/run observability.
- **Status-like fields used**:
- `status` (`queued|running|completed`)
- `outcome` (for example, `pending|succeeded|partial|failed|cancelled|...`)
### InventorySyncRun
- **Role**: inventory synchronization history and last-sync indicators.
- **Status-like fields used**:
- `status` (`pending|running|success|partial|failed|skipped`)
### BackupScheduleRun
- **Role**: scheduled backup history and status indicators.
- **Status-like fields used**:
- `status` (`running|success|partial|failed|canceled|skipped`)
### EntraGroupSyncRun
- **Role**: Entra group cache synchronization history.
- **Status-like fields used**:
- `status` (`pending|running|succeeded|partial|failed`)
- `safety_stop_triggered` (boolean)
### RestoreRun
- **Role**: restore orchestration progress and outcomes.
- **Status-like fields used**:
- `status` (enum-like string; see `RestoreRunStatus`)
### Finding
- **Role**: drift/risk indicators and triage workflow.
- **Status-like fields used**:
- `status` (`new|acknowledged`)
- `severity` (`low|medium|high`)
## Entities (code-only)
### Badge Domain
- **Role**: a named namespace for status-like values that must render consistently (for example, “inventory sync run status”, “drift finding severity”).
- **Uniqueness**: domain name is unique within the application.
### Badge Definition
- **Role**: the central mapping for a domains values → badge meaning.
- **Behavior**:
- Must be pure (no DB queries, no HTTP, no side effects).
- Must provide a safe default for unknown values.
## State transitions (existing)
### RestoreRunStatus (selected)
The restore run lifecycle is defined in code and must not be changed by this feature:
- Pre-flight states: draft → scoped → checked → previewed
- Execution: pending/queued → running → completed/partial/failed/cancelled
This feature standardizes how these values render; it does not change transitions.

View File

@ -0,0 +1,150 @@
# Implementation Plan: Unified Badge System (Single Source of Truth) v1
**Branch**: `059-unified-badges` | **Date**: 2026-01-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Standardize status/health and severity/risk badge semantics suite-wide using a centralized mapping system.
- Migrate all status-like badge surfaces (tables, dashboards/KPIs, detail views) to use the central semantics.
- Keep tag/category chips (policy type/platform/environment) out of scope for v1 (planned follow-up).
- Add automated regression coverage: mapping tests + a lightweight guard that flags reintroduced ad-hoc mappings.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4
**Storage**: PostgreSQL
**Testing**: Pest v4 (PHPUnit v12 runtime via `php artisan test`)
**Target Platform**: Web application (Filament admin panel)
**Project Type**: Web (Laravel monolith)
**Performance Goals**: Badge mapping is constant-time; no added queries or N+1; typical list pages render <2s for normal tenant sizes.
**Constraints**: Tenant-scoped; status-like badge rendering must be DB-only and must not trigger outbound HTTP or job dispatch during render/polling/hydration.
**Scale/Scope**: Suite-wide migration for status/health and severity/risk badges across tables, dashboards/KPIs, and detail views.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log
- 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
Status: ✅ No constitution violations (UI semantics only; no new Graph calls; no new write behavior; badge mapping is pure and tenant-safe).
## Project Structure
### Documentation (this feature)
```text
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/
├── Filament/
│ ├── Pages/Monitoring/Operations.php # Update: migrate status/outcome badges to central mapping
│ ├── Resources/ # Update: status-like columns/entries across resources
│ └── Widgets/ # Update: status-like + severity badges in dashboard widgets
├── Support/
│ └── Badges/ # New: central badge semantics (status/health + severity/risk)
└── Models/ # Existing: status/severity sources (OperationRun, Finding, etc.)
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/
└── filament/ # Update: replace any ad-hoc status-like badge colors
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/
├── Feature/Guards/ # New: lightweight “no ad-hoc badge semantics” guard
└── Unit/ # New/updated: badge mapping tests per domain
```
**Structure Decision**: Laravel monolith + Filament v5 conventions. Centralize semantics in `app/Support/Badges` and consume from Filament resources/pages/widgets + Blade views.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
None.
## Phase 0 — Outline & Research (complete)
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/research.md`
- Key decisions captured:
- v1 scope: status/health + severity/risk badges suite-wide; tag/category chips deferred.
- Drift severity mapping: low = neutral, medium = warning, high = danger.
- Enforcement: mapping tests + lightweight guard to prevent reintroducing ad-hoc mappings.
## Phase 1 — Design & Contracts (complete)
### Data model
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/data-model.md`
- No schema changes required; badge semantics derive from existing fields (status/outcome/severity booleans).
### Contracts
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/contracts/badge-semantics.md`
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/contracts/guardrails.md`
### Quickstart
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/quickstart.md`
### Provider registration (Laravel 11+)
- Panel providers remain registered in `bootstrap/providers.php` (no changes required for this feature unless adding a new provider).
### Livewire / Filament version safety
- Livewire v4.0+ (required by Filament v5) is in use.
### Asset strategy
- No new assets expected. If new panel assets are introduced during implementation, ensure deployment runs `php artisan filament:assets`.
### Destructive actions
- None introduced in this feature.
### Constitution re-check (post-design)
- ✅ Inventory-first / Snapshots-second: unaffected (UI-only semantics).
- ✅ Read/write separation: this feature is read-only.
- ✅ Graph contract path: no Graph calls added.
- ✅ Tenant isolation: badge mapping is pure and uses already-available tenant-scoped data.
- ✅ Run observability: only consumes existing run records; does not introduce new long-running work.
- ✅ Data minimization: no new payload storage.
**Gate status (post-design)**: PASS
## Phase 2 — Implementation Plan (next)
### Story 1 (P1): Trustworthy status/health badges everywhere
- Introduce a central badge semantics layer for status/health domains (runs, findings status, tenant status, booleans, availability).
- Migrate all status-like badge surfaces suite-wide to the centralized mapping, prioritizing:
- Monitoring/Operations list surfaces
- Inventory sync runs and backup schedule runs
- Restore runs
- Findings status
- Ensure the invariant: success/completed is never presented as warning/attention.
### Story 2 (P2): Readable status badges in dark mode
- Remove fragile per-page color overrides for status-like badges in Blade where present.
- Ensure status-like badges remain readable in dark mode and icons do not appear disabled unless intentionally neutral.
### Story 3 (P3): Consistency stays enforced over time
- Add mapping tests per domain (including drift severity mapping and “success is never warning” invariants).
- Add a lightweight guard test to detect newly introduced ad-hoc status/health or severity/risk badge mappings.

View File

@ -0,0 +1,29 @@
# Quickstart — Unified Badge System (v1)
## Prereqs
- Run everything via Sail.
## Setup
- `vendor/bin/sail up -d`
- `vendor/bin/sail composer install`
## Run tests (targeted)
Existing safety nets to keep green:
- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsDbOnlyTest.php`
- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsTenantScopeTest.php`
When the feature is implemented, add + run:
- Badge mapping tests (new).
- Status-like ad-hoc mapping guard test (new).
## Manual QA (tenant-scoped)
- Operations/Monitoring: queued/running/completed + outcome badges are consistent and success is never warning.
- Drift findings: severity mapping is consistent (low=neutral, medium=warning, high=danger).
- Restore runs: all lifecycle statuses render consistently across list + detail.
- Dark mode: status-like badges remain readable; icons dont appear disabled unless intentionally neutral.
## Frontend assets
If UI changes dont show:
- `vendor/bin/sail npm run dev`
- or `vendor/bin/sail npm run build`

View File

@ -0,0 +1,62 @@
# Research — Unified Badge System (Single Source of Truth) v1
## Goal
Standardize status/health and severity/risk badge semantics suite-wide so operators can reliably scan the admin UI without misread signals (for example, “success” never appearing as warning).
V1 scope explicitly excludes tag/category chips (policy type/platform/environment).
## Existing Code & Patterns (to reuse)
### Filament badge surfaces (current)
- Tables already use `TextColumn::badge()` in many places.
- Ad-hoc status and severity mapping exists in several hotspots, for example:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Monitoring/Operations.php`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Dashboard/RecentDriftFindings.php`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/restore-run-checks.blade.php`
### Guard test pattern (current)
- The repo already uses Pest “guard” tests that scan the codebase for forbidden patterns:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoLegacyBulkOperationsTest.php`
### Status / severity sources (current)
Status-like values already exist in models/enums and must remain the source of truth for meaning:
- Operation runs: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/OperationRun.php` (status) + outcome usage in UI.
- Inventory sync runs: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/InventorySyncRun.php` (status constants).
- Backup schedule runs: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/BackupScheduleRun.php` (status constants).
- Entra group sync runs: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/EntraGroupSyncRun.php` (status constants).
- Restore runs: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/RestoreRunStatus.php` (enum).
- Findings: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/Finding.php` (severity + status constants).
## Key Decisions
### Decision: Centralize status-like badge semantics behind domain mappers
- **Decision**: Introduce a central badge semantics layer that maps a “domain” + “value” to a single badge meaning (label + color meaning + optional icon).
- **Rationale**: Eliminates drift from per-page match blocks and keeps UI semantics testable and reviewable.
- **Alternatives considered**:
- **Inline mappings per resource/widget**: rejected (drifts quickly; hard to enforce).
- **Config-only mappings**: rejected for v1 (harder to type-check; still needs a rendering abstraction).
- **DB-stored mappings**: rejected (adds runtime dependency and migration/tenant complexity for no user value).
### Decision: V1 scope is “status-like” only (status/health + severity/risk)
- **Decision**: V1 migrates status-like badges suite-wide; tag/category chips are deferred.
- **Rationale**: Status/health and severity/risk are the highest-risk trust killers when inconsistent; tags are valuable but less safety-critical and more domain-specific.
### Decision: Canonical drift severity meanings
- **Decision**: Drift finding severity mapping is canonical: low = neutral, medium = warning, high = danger.
- **Rationale**: Severity is a risk/attention signal; “low” should not appear as “success”.
### Decision: No severity taxonomy changes in v1
- **Decision**: Do not add/rename severity levels (for example, do not introduce “critical” in v1).
- **Rationale**: This feature standardizes rendering semantics; changing underlying severity taxonomy is a separate scope and needs domain review.
### Decision: Enforcement is tests + a lightweight guard
- **Decision**: Add:
- Mapping tests per domain (including invariants like “success is never warning”).
- A lightweight guard test that flags newly introduced ad-hoc mappings for status/health and severity/risk.
- **Rationale**: Mapping tests prove correctness; the guard prevents regressions and enforces the single-source-of-truth rule.
- **Alternatives considered**:
- **Strict guard banning any badge usage not from the central system**: rejected (too brittle; would block deferred tag/category chip work and legitimate non-status uses).
## Open Questions
None — remaining work is implementation-time discovery of all status-like badge surfaces to migrate.

View File

@ -0,0 +1,151 @@
# Feature Specification: Unified Badge System (Single Source of Truth) v1
**Feature Branch**: `059-unified-badges`
**Created**: 2026-01-22
**Status**: Draft
**Input**: Suite-wide badge/chip standardization so the same underlying value always renders with the same meaning (label + color + optional icon) across tables, dashboards/KPIs, and detail views; tenant-safe and DB-only at render time where required.
## Clarifications
### Session 2026-01-22
- Q: What is the v1 migration coverage target? → A: Status-like badges suite-wide; tag/category chips later.
- Q: What counts as “status-like” for v1 scope? → A: Status/health plus severity/risk signals.
- Q: Should v1 introduce any new severity levels (e.g., “critical”), or standardize the existing severity values only? → A: Standardize existing severity values only (no new levels in v1).
- Q: Should v1 include an automated “no ad-hoc badge semantics” guard beyond mapping tests? → A: Yes — tests plus a lightweight automated guard that flags ad-hoc mappings.
- Q: What is the canonical meaning for drift finding severity (low | medium | high) in v1? → A: low = neutral, medium = warning, high = danger.
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Trustworthy status badges everywhere (Priority: P1)
As a tenant admin, I can trust that status/health and severity/risk badges mean the same thing everywhere in the admin panel, so I can scan lists quickly and make the right decision.
**Why this priority**: Inconsistent badge semantics create operational mistakes (false “success”, missed failures) and erode trust in the UI.
**Independent Test**: View a representative set of pages that contain status/health and severity/risk badges (lists + detail views) and confirm the same underlying value always uses the same label and visual meaning across pages.
**Acceptance Scenarios**:
1. **Given** a run is in the “queued” state, **When** I view it in any table, dashboard list, or detail view, **Then** it is clearly shown as “Queued” with a consistent visual meaning that indicates “waiting to start”.
2. **Given** a run is in the “running” state, **When** I view it in any table, dashboard list, or detail view, **Then** it is clearly shown as “Running” with a consistent visual meaning that indicates “in progress”.
3. **Given** a run is in a successful terminal state (“succeeded” / “completed”), **When** I view it anywhere, **Then** it is shown with a consistent “success” meaning and is never shown using warning/attention colors.
4. **Given** a finding has a high-severity value, **When** I view it in any table, dashboard list, or detail view, **Then** it is shown with a consistent “high severity” meaning and is never shown as a neutral or low-attention meaning.
---
### User Story 2 - Readable status badges in dark mode (Priority: P2)
As a tenant admin, I can scan status badges in both light and dark mode without readability regressions.
**Why this priority**: Badges are a high-density UI element; readability and correct “good/bad” signaling reduce cognitive load and prevent mistakes.
**Independent Test**: Open key list pages and dashboards in dark mode and light mode and verify status badges remain readable without relying on fragile per-page styling overrides.
**Acceptance Scenarios**:
1. **Given** I use dark mode, **When** I view status-like badges on common pages, **Then** badge text and any icons remain readable and do not rely on fragile per-page styling overrides.
2. **Given** a status badge includes an icon in a dense list, **When** I view it, **Then** the icon appearance matches the badge meaning and does not appear disabled unless the status is intentionally neutral.
---
### User Story 3 - Consistency stays enforced over time (Priority: P3)
As a maintainer, I can update badge semantics in one place and have the change apply everywhere, and regressions are caught before release.
**Why this priority**: Without enforcement, ad-hoc badge mappings quickly reappear and the UI drifts back into inconsistent meanings.
**Independent Test**: Make a small change to a centralized badge definition and confirm it affects multiple UI surfaces; introduce a deliberately inconsistent mapping and confirm automated validation fails.
**Acceptance Scenarios**:
1. **Given** a new status value is introduced, **When** it is not yet defined in the central badge system, **Then** it displays with a safe “unknown” meaning rather than being misrepresented as success or warning.
2. **Given** a developer attempts to reintroduce an ad-hoc badge mapping, **When** automated validation runs, **Then** it is detected and fails until the centralized definition is used.
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
- A record has an unrecognized/legacy status value (null/empty/unknown string).
- A record has an unrecognized/legacy severity/risk value.
- The same status appears on multiple pages (list + detail) and must remain consistent.
- A status value exists across multiple “domains” (e.g., “completed” used in different workflows) and must not be conflated if meanings differ.
- Dark mode and high-contrast settings reduce readability of badge text or icons.
- A page that must remain read-only/DB-only accidentally introduces side effects during render (for example, remote calls or background work).
- Tenant switching occurs mid-session and badges must not leak cross-tenant data.
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: The system MUST define badge semantics centrally for each status badge “domain” used in the admin UI, including: label, color meaning, and (when applicable) an icon.
- **FR-002**: The system MUST apply the centralized status badge semantics consistently across all status badge surfaces (tables, dashboards/KPIs, and detail views) so the same underlying value always renders with the same meaning.
- **FR-003**: The system MUST clearly distinguish “status-like” badges (status/health and severity/risk signals) from tag/category chips so scope boundaries are unambiguous; v1 MUST standardize status-like badges suite-wide.
- **FR-004**: The system MUST standardize the canonical meanings for run-like statuses at minimum: queued, running, succeeded/completed, partial, failed.
- **FR-005**: Warning/attention colors (e.g., orange/yellow) MUST be reserved for “queued / needs attention / partial / in progress” meanings and MUST NOT represent success/completed outcomes.
- **FR-006**: Badge rendering MUST remain tenant-safe: it must not display cross-tenant data and must rely only on data already available in the current tenant context.
- **FR-007**: For designated DB-only pages (for example, Monitoring/Operations views), badge rendering MUST NOT trigger outbound network requests, background jobs, or other side effects during render or during automatic refresh/polling.
- **FR-008**: Badge rendering MUST be performant and predictable: it must not require additional data lookups at view time and must not introduce noticeable delays on high-row-count tables.
- **FR-009**: In dense tables, badges MAY include icons for scanability; when icons are shown, they MUST not appear disabled/gray unless the badge itself is intentionally neutral.
- **FR-010**: The system MUST provide a safe default for unrecognized values (neutral + clearly labeled as unknown) to avoid misleading operators.
- **FR-011**: The admin UI MUST be migrated so existing status-like badges/chips use the centralized system across the suite; tag/category chips are explicitly out of scope for v1 migration and may remain unchanged.
- **FR-012**: The delivery MUST include automated regression checks that validate canonical mappings (including “success is never warning”) and prevent reintroducing ad-hoc badge semantics.
- **FR-013**: This change MUST be limited to badge/chip rendering semantics; it MUST NOT change underlying workflow logic, status definitions, or page layouts beyond what is required to standardize badge rendering.
- **FR-014**: Severity/risk badges (for example, findings severity) MUST be standardized and rendered consistently across all in-scope pages.
- **FR-015**: The system MUST NOT introduce new severity levels as part of this feature; it MUST standardize and render existing severity values consistently.
- **FR-016**: The delivery MUST include a lightweight automated guard that detects newly introduced ad-hoc status/health or severity/risk badge semantics and blocks release until the centralized system is used.
- **FR-017**: Drift finding severity MUST have a canonical meaning: low = neutral, medium = warning, high = danger.
### Assumptions & Dependencies
- Existing status values and business meanings are already established; this feature standardizes how they are presented, not what they mean.
- A defined set of status-like badge domains exists across the suite (runs, findings status, tenant status, availability, enabled/disabled, severity/risk); any newly discovered status-like domains will be included in the v1 standardization scope.
- Dark mode is supported and is considered in acceptance for badge readability.
- Tag/category chip standardization (policy type/platform/environment) is deferred to a later version.
- Severity level changes (such as adding “critical”) are deferred to a later version.
### Key Entities *(include if feature involves data)*
- **Badge Domain**: A named category of values that share a consistent badge meaning (for example, “Operation run status”).
- **Badge Definition**: The centralized mapping for a domains values to label + color meaning + optional icon.
- **Status Badge**: A badge that communicates progress/outcome/health or severity/risk (for example, queued/running/succeeded).
- **Tag Badge**: A badge that communicates categorization/metadata (for example, platform/type/environment).
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: For each defined status badge domain, the same value renders with the same label and visual meaning across all in-scope pages in 100% of validation runs.
- **SC-002**: Across the in-scope admin UI, 0 instances exist where a success/completed outcome is presented using a warning/attention badge meaning.
- **SC-003**: Viewing designated DB-only pages triggers 0 outbound network requests and 0 background work as a side effect of badge rendering, in 100% of regression runs.
- **SC-004**: Status-badge-related UI regressions (incorrect label/color/icon meaning) decrease by at least 80% in the 30 days after release compared to the previous 30 days.

View File

@ -0,0 +1,176 @@
---
description: "Task list for feature implementation"
---
# Tasks: Unified Badge System (Single Source of Truth) v1
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/`
**Tests**: Required (Pest) — this feature changes runtime UI semantics and adds regression guardrails.
---
## Phase 1: Setup (Shared Infrastructure)
- [X] T001 Confirm feature inputs exist: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/spec.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/plan.md`
- [X] T002 Confirm Phase 0/1 artifacts exist: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/research.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/contracts/badge-semantics.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/contracts/guardrails.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/quickstart.md`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the centralized badge semantics layer that all user stories depend on.
- [X] T003 Create badge value object in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeSpec.php`
- [X] T004 Create badge domain + mapper contracts in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeDomain.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeMapper.php`
- [X] T005 Create central resolver/registry in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeCatalog.php` (safe unknown fallback; no side effects)
- [X] T006 Create Filament + Blade helper closures in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeRenderer.php` (table/infolist helpers for status-like badges)
- [X] T007 Add foundational unit coverage for unknown fallback + allowed color set in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/BadgeCatalogTest.php`
**Checkpoint**: Central badge semantics infrastructure exists and is covered.
---
## Phase 3: User Story 1 — Trustworthy status/health + severity/risk badges everywhere (Priority: P1) 🎯 MVP
**Goal**: Status-like values (status/health and severity/risk) render consistently across the suite, using central semantics.
**Independent Test**: Run badge mapper tests and verify key pages (Operations + Drift findings + Restore runs) show consistent meanings, including “success is never warning”.
### Tests (US1)
- [X] T008 [P] [US1] Add OperationRun badge mapping tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`
- [X] T009 [P] [US1] Add Finding status + severity mapping tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/FindingBadgesTest.php`
- [X] T010 [P] [US1] Add RestoreRun status mapping tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreRunBadgesTest.php`
- [X] T011 [P] [US1] Add InventorySyncRun + BackupScheduleRun mapping tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RunStatusBadgesTest.php`
### Implementation (US1)
- [X] T012 [US1] Implement OperationRun status/outcome badge domains in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunStatusBadge.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunOutcomeBadge.php`
- [X] T013 [US1] Implement drift finding severity badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/FindingSeverityBadge.php` (low=neutral, medium=warning, high=danger)
- [X] T014 [US1] Implement finding status badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/FindingStatusBadge.php`
- [X] T015 [US1] Implement RestoreRun status badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestoreRunStatusBadge.php`
- [X] T016 [US1] Implement InventorySyncRun status badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/InventorySyncRunStatusBadge.php`
- [X] T017 [US1] Implement BackupScheduleRun status badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php`
- [X] T018 [US1] Implement EntraGroupSyncRun status badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php`
- [X] T019 [US1] Implement status-like boolean badge domains in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/BooleanEnabledBadge.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/BooleanHasErrorsBadge.php`
### Migration (US1)
- [X] T020 [P] [US1] Migrate Operations resource badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php` (remove ad-hoc `statusColor()` / `outcomeColor()` logic)
- [X] T021 [P] [US1] Migrate Monitoring Operations table badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Monitoring/Operations.php` (remove `->colors([...])`)
- [X] T022 [P] [US1] Migrate dashboard “Recent Operations” badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Dashboard/RecentOperations.php`
- [X] T023 [P] [US1] Migrate dashboard “Recent Drift Findings” severity/status badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Dashboard/RecentDriftFindings.php`
- [X] T024 [P] [US1] Migrate Finding resource status/severity badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/FindingResource.php`
- [X] T025 [P] [US1] Migrate Inventory sync run status + had_errors badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/InventorySyncRunResource.php`
- [X] T026 [P] [US1] Migrate backup schedule “last run status” and runs relation manager badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/BackupScheduleResource.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php` (leave tag-like frequency badge unchanged in v1)
- [X] T027 [P] [US1] Migrate Entra group sync run status badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/EntraGroupSyncRunResource.php`
- [X] T028 [P] [US1] Migrate Restore run status badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/RestoreRunResource.php`
- [X] T029 [P] [US1] Migrate restore run check severity badges to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/restore-run-checks.blade.php`
- [X] T030 [US1] Sweep + migrate remaining status-like badge semantics in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Livewire/`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/` (status/health + severity/risk only; tag/category chips explicitly out of scope for v1)
**Checkpoint**: US1 is shippable as an MVP (status-like badges consistent across key surfaces).
---
## Phase 4: User Story 2 — Readable status badges in dark mode (Priority: P2)
**Goal**: Status-like badges remain readable in dark mode, without custom Tailwind chip overrides.
**Independent Test**: Open restore preview/results and other badge-heavy views in dark mode and confirm badges remain readable and consistent.
### Tests (US2)
- [X] T031 [P] [US2] Add restore preview/results decision/status mapping tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreUiBadgesTest.php`
### Implementation (US2)
- [X] T032 [US2] Introduce restore preview decision badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php`
- [X] T033 [US2] Introduce restore results status badge domain in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestoreResultStatusBadge.php`
- [X] T034 [US2] Replace custom Tailwind decision chips with Filament badges in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/restore-preview.blade.php` (status-like chips only; keep policy type/platform tags as-is)
- [X] T035 [US2] Replace custom Tailwind result/status chips with Filament badges in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/restore-results.blade.php` (status-like chips only)
**Checkpoint**: Dark mode readability is preserved on badge-heavy restore views.
---
## Phase 5: User Story 3 — Consistency stays enforced over time (Priority: P3)
**Goal**: Ad-hoc status-like badge semantics cannot be reintroduced without failing automated checks.
**Independent Test**: Introduce an ad-hoc status/severity mapping in a Filament surface and confirm the guard test fails.
### Tests + Guard (US3)
- [X] T036 [US3] Add “no ad-hoc status-like badge semantics” guard test in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` (pattern-based; allowlist tag/category chips)
**Checkpoint**: Guardrails prevent drift from reappearing.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T037 [P] Run formatting on changed files via `vendor/bin/sail php /Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/pint --dirty`
- [X] T038 Run targeted tests via `vendor/bin/sail artisan test /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/ /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
- [X] T039 Run quickstart verification steps from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/059-unified-badges/quickstart.md`
- [X] T040 [P] Add BackupSet status mapping tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/BackupSetBadgesTest.php` (completed must be success)
- [X] T041 Migrate BackupSet status badge to central mapping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/BackupSetResource.php` (completed must be green)
- [X] T042 Sweep for any remaining `completed` status-like badges rendered as warning and migrate to BadgeCatalog
- [X] T043 Fix Blade compilation for restore views (replace inline `@php(...)`) in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/restore-results.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/restore-preview.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/restore-run-checks.blade.php`
---
## Dependencies & Execution Order
### Dependency Graph (high-level)
```text
Phase 1 (Setup)
Phase 2 (Foundational badge system)
US1 (status/health + severity/risk migration) ──┬─→ US2 (dark mode restore views)
└─→ US3 (guard test)
Phase 6 (Polish)
```
### User Story Dependencies
- US1 (P1) depends on Foundational (Phase 2) and can ship as the MVP.
- US2 (P2) depends on Foundational (Phase 2); easiest after US1 establishes the core domains.
- US3 (P3) depends on Foundational (Phase 2) and should run after the first migrations to tune allowlists and reduce false positives.
Suggested MVP-first order: Phase 1 → Phase 2 → US1 → Phase 6 (minimum) → US2 → US3 → Phase 6 (final pass).
---
## Parallel Execution Examples (per user story)
### US1
- T008 [P] [US1] `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`
- T009 [P] [US1] `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/FindingBadgesTest.php`
- T010 [P] [US1] `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreRunBadgesTest.php`
- T011 [P] [US1] `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RunStatusBadgesTest.php`
- T020T029 [P] [US1] (migration tasks; different files)
### US2
- T031 [P] [US2] `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreUiBadgesTest.php`
### US3
- T036 [US3] `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
---
## Implementation Strategy
### MVP First (US1 only)
1. Complete Phase 1 + Phase 2
2. Implement US1 migrations for Operations + Drift findings + Restore runs first (T020T029)
3. Run: `vendor/bin/sail artisan test /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`
4. Run: `vendor/bin/sail artisan test /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/FindingBadgesTest.php`
### Incremental Delivery
- Finish US1 sweep (T030), then address restore Blade dark-mode hotspots in US2, then lock in regression prevention in US3.

View File

@ -0,0 +1,115 @@
<?php
use Illuminate\Support\Collection;
it('does not contain ad-hoc status-like badge semantics', function () {
$root = base_path();
$self = realpath(__FILE__);
$directories = [
$root.'/app/Filament',
$root.'/app/Livewire',
];
$excludedPaths = [
$root.'/vendor',
$root.'/storage',
$root.'/specs',
$root.'/spechistory',
$root.'/references',
$root.'/public/build',
];
$statusLikeTokenPattern = '/[\'"](?:queued|running|completed|pending|succeeded|partial|failed|cancelled|canceled|applied|dry_run|manual_required|mapped_existing|created|created_copy|skipped|blocking|acknowledged|new|low|medium|high)[\'"]/';
$inlineColorStartPattern = '/->color\\s*\\(\\s*(?:fn|function)\\b/';
$inlineLabelStartPattern = '/->formatStateUsing\\s*\\(\\s*(?:fn|function)\\b/';
$forbiddenPlainPatterns = [
'/\\bBadgeColumn::make\\b/',
'/->colors\\s*\\(/',
];
$lookaheadLines = 25;
/** @var Collection<int, string> $files */
$files = collect($directories)
->filter(fn (string $dir): bool => is_dir($dir))
->flatMap(function (string $dir): array {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
$paths = [];
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = $file->getPathname();
if (! str_ends_with($path, '.php')) {
continue;
}
$paths[] = $path;
}
return $paths;
})
->filter(function (string $path) use ($excludedPaths, $self): bool {
if ($self && realpath($path) === $self) {
return false;
}
foreach ($excludedPaths as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
})
->values();
$hits = [];
foreach ($files as $path) {
$contents = file_get_contents($path);
if (! is_string($contents) || $contents === '') {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($lines as $index => $line) {
foreach ($forbiddenPlainPatterns as $pattern) {
if (preg_match($pattern, $line)) {
$relative = str_replace($root.'/', '', $path);
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
if (preg_match($inlineColorStartPattern, $line)) {
$window = implode("\n", array_slice($lines, $index, $lookaheadLines));
if (preg_match($statusLikeTokenPattern, $window)) {
$relative = str_replace($root.'/', '', $path);
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
if (preg_match($inlineLabelStartPattern, $line)) {
$window = implode("\n", array_slice($lines, $index, $lookaheadLines));
if (preg_match($statusLikeTokenPattern, $window)) {
$relative = str_replace($root.'/', '', $path);
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
}
}
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
});

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps backup set status values to canonical badge semantics', function (): void {
$running = BadgeCatalog::spec(BadgeDomain::BackupSetStatus, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
$completed = BadgeCatalog::spec(BadgeDomain::BackupSetStatus, 'completed');
expect($completed->label)->toBe('Completed');
expect($completed->color)->toBe('success');
$partial = BadgeCatalog::spec(BadgeDomain::BackupSetStatus, 'partial');
expect($partial->label)->toBe('Partial');
expect($partial->color)->toBe('warning');
$failed = BadgeCatalog::spec(BadgeDomain::BackupSetStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
});

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeSpec;
it('returns a safe unknown badge spec for unknown values', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'not-a-real-status');
expect($spec)->toBeInstanceOf(BadgeSpec::class);
expect($spec->label)->toBe('Unknown');
expect($spec->color)->toBe('gray');
});
it('defines the allowed Filament badge colors', function (): void {
expect(BadgeSpec::allowedColors())->toBe([
'gray',
'info',
'success',
'warning',
'danger',
'primary',
]);
});

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps finding severity values to canonical badge semantics', function (): void {
$low = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'low');
expect($low->label)->toBe('Low');
expect($low->color)->toBe('gray');
$medium = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'medium');
expect($medium->label)->toBe('Medium');
expect($medium->color)->toBe('warning');
$high = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'high');
expect($high->label)->toBe('High');
expect($high->color)->toBe('danger');
});
it('maps finding status values to canonical badge semantics', function (): void {
$new = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'new');
expect($new->label)->toBe('New');
expect($new->color)->toBe('warning');
$acknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged');
expect($acknowledged->label)->toBe('Acknowledged');
expect($acknowledged->color)->toBe('gray');
});

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps operation run status values to canonical badge semantics', function (): void {
$queued = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'queued');
expect($queued->label)->toBe('Queued');
expect($queued->color)->toBe('warning');
$running = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
$completed = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'completed');
expect($completed->label)->toBe('Completed');
expect($completed->color)->toBe('gray');
});
it('maps operation run outcome values to canonical badge semantics', function (): void {
$pending = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'pending');
expect($pending->label)->toBe('Pending');
expect($pending->color)->toBe('gray');
$succeeded = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'succeeded');
expect($succeeded->label)->toBe('Succeeded');
expect($succeeded->color)->toBe('success');
$partial = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'partially_succeeded');
expect($partial->label)->toBe('Partially succeeded');
expect($partial->color)->toBe('warning');
$failed = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
$cancelled = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'cancelled');
expect($cancelled->label)->toBe('Cancelled');
expect($cancelled->color)->toBe('gray');
});
it('never represents a success outcome with warning/attention meaning', function (): void {
$succeeded = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'succeeded');
expect($succeeded->color)->not->toBe('warning');
});

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps policy snapshot mode values to canonical badge semantics', function (): void {
$full = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'full');
expect($full->label)->toBe('Full');
expect($full->color)->toBe('success');
$metadataOnly = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'metadata_only');
expect($metadataOnly->label)->toBe('Metadata only');
expect($metadataOnly->color)->toBe('warning');
});
it('maps policy restore mode values to canonical badge semantics', function (): void {
$enabled = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, 'enabled');
expect($enabled->label)->toBe('Enabled');
expect($enabled->color)->toBe('success');
$previewOnly = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, 'preview-only');
expect($previewOnly->label)->toBe('Preview only');
expect($previewOnly->color)->toBe('warning');
});
it('maps policy risk values to canonical badge semantics', function (): void {
$low = BadgeCatalog::spec(BadgeDomain::PolicyRisk, 'low');
expect($low->label)->toBe('Low');
expect($low->color)->toBe('gray');
$mediumHigh = BadgeCatalog::spec(BadgeDomain::PolicyRisk, 'medium-high');
expect($mediumHigh->label)->toBe('Medium-high');
expect($mediumHigh->color)->toBe('danger');
});
it('maps ignored-at presence to canonical badge semantics', function (): void {
$notIgnored = BadgeCatalog::spec(BadgeDomain::IgnoredAt, null);
expect($notIgnored->label)->toBe('No');
expect($notIgnored->color)->toBe('gray');
$ignored = BadgeCatalog::spec(BadgeDomain::IgnoredAt, '2026-01-01T00:00:00Z');
expect($ignored->label)->toBe('Yes');
expect($ignored->color)->toBe('warning');
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps restore run status values to canonical badge semantics', function (): void {
$draft = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'draft');
expect($draft->label)->toBe('Draft');
expect($draft->color)->toBe('gray');
$previewed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'previewed');
expect($previewed->label)->toBe('Previewed');
expect($previewed->color)->toBe('gray');
$queued = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'queued');
expect($queued->label)->toBe('Queued');
expect($queued->color)->toBe('warning');
$running = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
$completed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'completed');
expect($completed->label)->toBe('Completed');
expect($completed->color)->toBe('success');
$partial = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'partial');
expect($partial->label)->toBe('Partial');
expect($partial->color)->toBe('warning');
$failed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
});
it('never represents a completed outcome with warning/attention meaning', function (): void {
$completed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'completed');
expect($completed->color)->not->toBe('warning');
});
it('maps restore safety check severity values to canonical badge semantics', function (): void {
$blocking = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'blocking');
expect($blocking->label)->toBe('Blocking');
expect($blocking->color)->toBe('danger');
$warning = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'warning');
expect($warning->label)->toBe('Warning');
expect($warning->color)->toBe('warning');
$safe = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'safe');
expect($safe->label)->toBe('Safe');
expect($safe->color)->toBe('success');
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps restore preview decisions to canonical badge semantics', function (): void {
$created = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'created');
expect($created->label)->toBe('Created');
expect($created->color)->toBe('success');
$mappedExisting = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'mapped_existing');
expect($mappedExisting->label)->toBe('Mapped existing');
expect($mappedExisting->color)->toBe('info');
$failed = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
});
it('maps restore results statuses to canonical badge semantics', function (): void {
$applied = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'applied');
expect($applied->label)->toBe('Applied');
expect($applied->color)->toBe('success');
$dryRun = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'dry_run');
expect($dryRun->label)->toBe('Dry run');
expect($dryRun->color)->toBe('info');
$manualRequired = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'manual_required');
expect($manualRequired->label)->toBe('Manual required');
expect($manualRequired->color)->toBe('warning');
$failed = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
});

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps inventory sync run status values to canonical badge semantics', function (): void {
$pending = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'pending');
expect($pending->label)->toBe('Pending');
expect($pending->color)->toBe('gray');
$running = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
$success = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'success');
expect($success->label)->toBe('Success');
expect($success->color)->toBe('success');
$partial = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'partial');
expect($partial->label)->toBe('Partial');
expect($partial->color)->toBe('warning');
$failed = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
$skipped = BadgeCatalog::spec(BadgeDomain::InventorySyncRunStatus, 'skipped');
expect($skipped->label)->toBe('Skipped');
expect($skipped->color)->toBe('gray');
});
it('maps backup schedule run status values to canonical badge semantics', function (): void {
$running = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'running');
expect($running->label)->toBe('Running');
expect($running->color)->toBe('info');
$success = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'success');
expect($success->label)->toBe('Success');
expect($success->color)->toBe('success');
$partial = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'partial');
expect($partial->label)->toBe('Partial');
expect($partial->color)->toBe('warning');
$failed = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'failed');
expect($failed->label)->toBe('Failed');
expect($failed->color)->toBe('danger');
$canceled = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'canceled');
expect($canceled->label)->toBe('Canceled');
expect($canceled->color)->toBe('gray');
$skipped = BadgeCatalog::spec(BadgeDomain::BackupScheduleRunStatus, 'skipped');
expect($skipped->label)->toBe('Skipped');
expect($skipped->color)->toBe('gray');
});

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps tenant status values to canonical badge semantics', function (): void {
$active = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'active');
expect($active->label)->toBe('Active');
expect($active->color)->toBe('success');
$archived = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'archived');
expect($archived->label)->toBe('Archived');
expect($archived->color)->toBe('gray');
$suspended = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'suspended');
expect($suspended->label)->toBe('Suspended');
expect($suspended->color)->toBe('warning');
$error = BadgeCatalog::spec(BadgeDomain::TenantStatus, 'error');
expect($error->label)->toBe('Error');
expect($error->color)->toBe('danger');
});
it('maps tenant app status values to canonical badge semantics', function (): void {
$ok = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'ok');
expect($ok->label)->toBe('OK');
expect($ok->color)->toBe('success');
$consentRequired = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'consent_required');
expect($consentRequired->label)->toBe('Consent required');
expect($consentRequired->color)->toBe('warning');
$error = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'error');
expect($error->label)->toBe('Error');
expect($error->color)->toBe('danger');
});
it('maps tenant RBAC status values to canonical badge semantics', function (): void {
$configured = BadgeCatalog::spec(BadgeDomain::TenantRbacStatus, 'configured');
expect($configured->label)->toBe('Configured');
expect($configured->color)->toBe('success');
$manual = BadgeCatalog::spec(BadgeDomain::TenantRbacStatus, 'manual_assignment_required');
expect($manual->label)->toBe('Manual assignment required');
expect($manual->color)->toBe('warning');
$notConfigured = BadgeCatalog::spec(BadgeDomain::TenantRbacStatus, 'not_configured');
expect($notConfigured->label)->toBe('Not configured');
expect($notConfigured->color)->toBe('gray');
});
it('maps tenant permission status values to canonical badge semantics', function (): void {
$granted = BadgeCatalog::spec(BadgeDomain::TenantPermissionStatus, 'granted');
expect($granted->label)->toBe('Granted');
expect($granted->color)->toBe('success');
$missing = BadgeCatalog::spec(BadgeDomain::TenantPermissionStatus, 'missing');
expect($missing->label)->toBe('Missing');
expect($missing->color)->toBe('warning');
$error = BadgeCatalog::spec(BadgeDomain::TenantPermissionStatus, 'error');
expect($error->label)->toBe('Error');
expect($error->color)->toBe('danger');
});