diff --git a/app/Filament/Pages/Monitoring/Operations.php b/app/Filament/Pages/Monitoring/Operations.php index 394d983..df3300d 100644 --- a/app/Filament/Pages/Monitoring/Operations.php +++ b/app/Filament/Pages/Monitoring/Operations.php @@ -3,6 +3,8 @@ namespace App\Filament\Pages\Monitoring; use App\Models\OperationRun; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationCatalog; use BackedEnum; use Filament\Facades\Filament; @@ -51,21 +53,17 @@ public function table(Table $table): Table TextColumn::make('status') ->badge() - ->colors([ - 'secondary' => 'queued', - 'warning' => 'running', - 'success' => 'completed', - ]), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), TextColumn::make('outcome') ->badge() - ->colors([ - 'gray' => 'pending', - 'success' => 'succeeded', - 'warning' => 'partially_succeeded', - 'danger' => 'failed', - 'secondary' => 'cancelled', - ]), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) + ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) + ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), TextColumn::make('initiator_name') ->label('Initiator') diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index a7d10c0..d2d6d9a 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -15,6 +15,8 @@ use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -215,23 +217,35 @@ public static function table(Table $table): Table TextColumn::make('last_run_status') ->label('Last run status') ->badge() - ->formatStateUsing(fn (?string $state): string => match ($state) { - BackupScheduleRun::STATUS_RUNNING => 'Running', - BackupScheduleRun::STATUS_SUCCESS => 'Success', - BackupScheduleRun::STATUS_PARTIAL => 'Partial', - BackupScheduleRun::STATUS_FAILED => 'Failed', - BackupScheduleRun::STATUS_CANCELED => 'Canceled', - BackupScheduleRun::STATUS_SKIPPED => 'Skipped', - default => $state ? Str::headline($state) : '—', + ->formatStateUsing(function (?string $state): string { + if (! filled($state)) { + return '—'; + } + + return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label; }) - ->color(fn (?string $state): string => match ($state) { - BackupScheduleRun::STATUS_SUCCESS => 'success', - BackupScheduleRun::STATUS_PARTIAL => 'warning', - BackupScheduleRun::STATUS_RUNNING => 'primary', - BackupScheduleRun::STATUS_SKIPPED => 'gray', - BackupScheduleRun::STATUS_FAILED, - BackupScheduleRun::STATUS_CANCELED => 'danger', - default => 'gray', + ->color(function (?string $state): string { + if (! filled($state)) { + return 'gray'; + } + + return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color; + }) + ->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') diff --git a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php index bed6e62..d0b1e99 100644 --- a/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php +++ b/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php @@ -5,6 +5,8 @@ use App\Filament\Resources\BackupSetResource; use App\Models\BackupScheduleRun; use App\Models\Tenant; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use Filament\Actions; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; @@ -27,15 +29,10 @@ public function table(Table $table): Table ->dateTime(), Tables\Columns\TextColumn::make('status') ->badge() - ->color(fn (?string $state): string => match ($state) { - BackupScheduleRun::STATUS_SUCCESS => 'success', - BackupScheduleRun::STATUS_PARTIAL => 'warning', - BackupScheduleRun::STATUS_RUNNING => 'primary', - BackupScheduleRun::STATUS_SKIPPED => 'gray', - BackupScheduleRun::STATUS_FAILED, - BackupScheduleRun::STATUS_CANCELED => 'danger', - default => 'gray', - }), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus)) + ->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)), Tables\Columns\TextColumn::make('duration') ->label('Duration') ->getStateUsing(function (BackupScheduleRun $record): string { diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 178cd19..4280ded 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -14,6 +14,8 @@ use App\Services\Intune\BackupService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use BackedEnum; @@ -57,7 +59,12 @@ public static function table(Table $table): Table return $table ->columns([ 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('created_by')->label('Created by'), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), @@ -401,7 +408,12 @@ public static function infolist(Schema $schema): Schema return $schema ->schema([ 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('created_by')->label('Created by'), Infolists\Components\TextEntry::make('completed_at')->dateTime(), diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index d580d33..cd7ad76 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -8,6 +8,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -56,12 +58,18 @@ public function table(Table $table): Table ->label('Restore') ->badge() ->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') ->label('Risk') ->badge() ->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') ->label('Policy ID') ->copyable(), diff --git a/app/Filament/Resources/EntraGroupSyncRunResource.php b/app/Filament/Resources/EntraGroupSyncRunResource.php index 259dc8a..6d08475 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource.php @@ -5,6 +5,8 @@ use App\Filament\Resources\EntraGroupSyncRunResource\Pages; use App\Models\EntraGroupSyncRun; use App\Models\Tenant; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use BackedEnum; use Filament\Actions; @@ -60,7 +62,10 @@ public static function infolist(Schema $schema): Schema ->placeholder('—'), TextEntry::make('status') ->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('slot_key')->label('Slot')->placeholder('—')->copyable(), TextEntry::make('started_at')->dateTime(), @@ -106,7 +111,10 @@ public static function table(Table $table): Table ->toggleable(), Tables\Columns\TextColumn::make('status') ->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') ->label('Selection') ->limit(24) @@ -143,16 +151,4 @@ public static function getPages(): array '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', - }; - } } diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index a78c03a..764768d 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -9,6 +9,8 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Drift\DriftFindingDiffBuilder; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use BackedEnum; use Filament\Actions; use Filament\Actions\BulkAction; @@ -50,8 +52,18 @@ public static function infolist(Schema $schema): Schema Section::make('Finding') ->schema([ TextEntry::make('finding_type')->badge()->label('Type'), - TextEntry::make('status')->badge(), - TextEntry::make('severity')->badge(), + TextEntry::make('status') + ->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('scope_key')->label('Scope')->copyable(), TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'), @@ -188,8 +200,18 @@ public static function table(Table $table): Table ->defaultSort('created_at', 'desc') ->columns([ Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'), - Tables\Columns\TextColumn::make('status')->badge(), - Tables\Columns\TextColumn::make('severity')->badge(), + Tables\Columns\TextColumn::make('status') + ->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_type')->label('Subject type')->searchable(), Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 5f32f5c..044db33 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -6,6 +6,8 @@ use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use BackedEnum; use Filament\Actions; @@ -66,14 +68,23 @@ public static function infolist(Schema $schema): Schema ->placeholder('—'), TextEntry::make('status') ->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('started_at')->dateTime(), TextEntry::make('finished_at')->dateTime(), TextEntry::make('items_observed_count')->label('Observed')->numeric(), TextEntry::make('items_upserted_count')->label('Upserted')->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) ->columnSpanFull(), @@ -116,7 +127,10 @@ public static function table(Table $table): Table ->toggleable(), Tables\Columns\TextColumn::make('status') ->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') ->label('Selection') ->copyable() @@ -155,16 +169,4 @@ public static function getPages(): array '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', - }; - } } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 3cc29b2..012b33c 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -5,6 +5,8 @@ use App\Filament\Resources\OperationRunResource\Pages; use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationCatalog; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -63,10 +65,16 @@ public static function infolist(Schema $schema): Schema ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)), TextEntry::make('status') ->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') ->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('target_scope_display') ->label('Target') @@ -147,7 +155,10 @@ public static function table(Table $table): Table ->columns([ Tables\Columns\TextColumn::make('status') ->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') ->label('Operation') ->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') ->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([ 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 { $context = is_array($record->context) ? $record->context : []; diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index 4a2ef0a..1d082cf 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -14,6 +14,8 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -66,8 +68,11 @@ public static function infolist(Schema $schema): Schema TextEntry::make('latest_snapshot_mode') ->label('Snapshot') ->badge() - ->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success') - ->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full') + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode)) + ->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 { $meta = static::latestVersionMetadata($record); diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 029a07c..c7b7aac 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -21,6 +21,8 @@ use App\Services\Intune\RestoreService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -709,7 +711,12 @@ public static function table(Table $table): Table ->columns([ Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), 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') ->label('Total') ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)), @@ -1251,7 +1258,12 @@ public static function infolist(Schema $schema): Schema return $schema ->schema([ 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') ->label('Counts') ->state(function (RestoreRun $record): string { diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index b5e120e..2d5504b 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -17,6 +17,8 @@ use App\Services\Intune\TenantPermissionService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -149,9 +151,17 @@ public static function table(Table $table): Table ->boolean(), Tables\Columns\TextColumn::make('status') ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus)) + ->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)) ->sortable(), 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') ->dateTime() ->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('status') ->badge() - ->color(fn (string $state): string => match ($state) { - 'active' => 'success', - 'inactive' => 'gray', - 'suspended' => 'warning', - 'error' => 'danger', - default => 'gray', - }), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus)) + ->color(BadgeRenderer::color(BadgeDomain::TenantStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)), Infolists\Components\TextEntry::make('app_status') ->badge() - ->color(fn (string $state): string => match ($state) { - 'ok', 'configured' => 'success', - 'pending' => 'warning', - 'error' => 'danger', - 'requires_consent' => 'warning', - default => 'gray', - }), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus)) + ->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)), Infolists\Components\TextEntry::make('app_notes')->label('Notes'), Infolists\Components\TextEntry::make('created_at')->dateTime(), Infolists\Components\TextEntry::make('updated_at')->dateTime(), Infolists\Components\TextEntry::make('rbac_status') ->label('RBAC status') ->badge() - ->color(fn (string $state): string => match ($state) { - 'ok', 'configured' => 'success', - 'manual_assignment_required' => 'warning', - 'error', 'failed' => 'danger', - 'not_configured' => 'gray', - default => 'gray', - }), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus)) + ->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus)), 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_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), Infolists\Components\TextEntry::make('status') ->badge() - ->color(fn (string $state): string => match ($state) { - 'granted' => 'success', - 'missing' => 'warning', - 'error' => 'danger', - default => 'gray', - }), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus)) + ->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)), ]) ->columnSpanFull(), ]); diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php index 448046a..8e6a462 100644 --- a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -8,6 +8,8 @@ use App\Models\Finding; use App\Models\InventoryItem; use App\Models\Tenant; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; 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), TextColumn::make('severity') ->badge() - ->color(fn (Finding $record): string => match ($record->severity) { - Finding::SEVERITY_HIGH => 'danger', - Finding::SEVERITY_MEDIUM => 'warning', - default => 'gray', - }), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity)) + ->color(BadgeRenderer::color(BadgeDomain::FindingSeverity)) + ->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), TextColumn::make('status') ->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') ->label('Created') ->since(), diff --git a/app/Filament/Widgets/Dashboard/RecentOperations.php b/app/Filament/Widgets/Dashboard/RecentOperations.php index 9c9428e..55330b2 100644 --- a/app/Filament/Widgets/Dashboard/RecentOperations.php +++ b/app/Filament/Widgets/Dashboard/RecentOperations.php @@ -6,6 +6,8 @@ use App\Models\OperationRun; use App\Models\Tenant; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationCatalog; use App\Support\OperationRunLinks; 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)), TextColumn::make('status') ->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') ->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') ->label('Started') ->since(), @@ -70,25 +78,4 @@ private function getQuery(): Builder ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->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', - }; - } } diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index 4553c11..3e601dd 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -9,6 +9,8 @@ use App\Models\User; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -107,8 +109,10 @@ public function table(Table $table): Table TextColumn::make('ignored_at') ->label('Ignored') ->badge() - ->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray') - ->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no') + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::IgnoredAt)) + ->color(BadgeRenderer::color(BadgeDomain::IgnoredAt)) + ->icon(BadgeRenderer::icon(BadgeDomain::IgnoredAt)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::IgnoredAt)) ->toggleable(isToggledHiddenByDefault: true), ]) ->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions')) diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php new file mode 100644 index 0000000..1930a23 --- /dev/null +++ b/app/Support/Badges/BadgeCatalog.php @@ -0,0 +1,122 @@ +> + */ + 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 + */ + 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; + } +} diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php new file mode 100644 index 0000000..43162eb --- /dev/null +++ b/app/Support/Badges/BadgeDomain.php @@ -0,0 +1,29 @@ +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); + } +} diff --git a/app/Support/Badges/BadgeSpec.php b/app/Support/Badges/BadgeSpec.php new file mode 100644 index 0000000..1211b80 --- /dev/null +++ b/app/Support/Badges/BadgeSpec.php @@ -0,0 +1,61 @@ + + */ + 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 + */ + 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', + ); + } +} diff --git a/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php b/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php new file mode 100644 index 0000000..cd60b74 --- /dev/null +++ b/app/Support/Badges/Domains/BackupScheduleRunStatusBadge.php @@ -0,0 +1,26 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/BackupSetStatusBadge.php b/app/Support/Badges/Domains/BackupSetStatusBadge.php new file mode 100644 index 0000000..661241e --- /dev/null +++ b/app/Support/Badges/Domains/BackupSetStatusBadge.php @@ -0,0 +1,23 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/BooleanEnabledBadge.php b/app/Support/Badges/Domains/BooleanEnabledBadge.php new file mode 100644 index 0000000..f9bbc3f --- /dev/null +++ b/app/Support/Badges/Domains/BooleanEnabledBadge.php @@ -0,0 +1,21 @@ + new BadgeSpec('Enabled', 'success', 'heroicon-m-check-circle'), + 'false', '0', 'no', 'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/BooleanHasErrorsBadge.php b/app/Support/Badges/Domains/BooleanHasErrorsBadge.php new file mode 100644 index 0000000..482b2e5 --- /dev/null +++ b/app/Support/Badges/Domains/BooleanHasErrorsBadge.php @@ -0,0 +1,21 @@ + new BadgeSpec('Errors', 'danger', 'heroicon-m-x-circle'), + 'false', '0', 'no' => new BadgeSpec('No errors', 'success', 'heroicon-m-check-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php b/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php new file mode 100644 index 0000000..b01b341 --- /dev/null +++ b/app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php @@ -0,0 +1,25 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/FindingSeverityBadge.php b/app/Support/Badges/Domains/FindingSeverityBadge.php new file mode 100644 index 0000000..d168ca3 --- /dev/null +++ b/app/Support/Badges/Domains/FindingSeverityBadge.php @@ -0,0 +1,23 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/FindingStatusBadge.php b/app/Support/Badges/Domains/FindingStatusBadge.php new file mode 100644 index 0000000..adb63ac --- /dev/null +++ b/app/Support/Badges/Domains/FindingStatusBadge.php @@ -0,0 +1,22 @@ + new BadgeSpec('New', 'warning', 'heroicon-m-clock'), + Finding::STATUS_ACKNOWLEDGED => new BadgeSpec('Acknowledged', 'gray', 'heroicon-m-check-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/IgnoredAtBadge.php b/app/Support/Badges/Domains/IgnoredAtBadge.php new file mode 100644 index 0000000..c72f795 --- /dev/null +++ b/app/Support/Badges/Domains/IgnoredAtBadge.php @@ -0,0 +1,21 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/OperationRunOutcomeBadge.php b/app/Support/Badges/Domains/OperationRunOutcomeBadge.php new file mode 100644 index 0000000..cb1cc27 --- /dev/null +++ b/app/Support/Badges/Domains/OperationRunOutcomeBadge.php @@ -0,0 +1,25 @@ +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(), + }; + } +} diff --git a/app/Support/Badges/Domains/OperationRunStatusBadge.php b/app/Support/Badges/Domains/OperationRunStatusBadge.php new file mode 100644 index 0000000..612e0f5 --- /dev/null +++ b/app/Support/Badges/Domains/OperationRunStatusBadge.php @@ -0,0 +1,23 @@ +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(), + }; + } +} diff --git a/app/Support/Badges/Domains/PolicyRestoreModeBadge.php b/app/Support/Badges/Domains/PolicyRestoreModeBadge.php new file mode 100644 index 0000000..1ed1ee2 --- /dev/null +++ b/app/Support/Badges/Domains/PolicyRestoreModeBadge.php @@ -0,0 +1,21 @@ + new BadgeSpec('Enabled', 'success', 'heroicon-m-check-circle'), + 'preview_only' => new BadgeSpec('Preview only', 'warning', 'heroicon-m-exclamation-triangle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/PolicyRiskBadge.php b/app/Support/Badges/Domains/PolicyRiskBadge.php new file mode 100644 index 0000000..3fac21e --- /dev/null +++ b/app/Support/Badges/Domains/PolicyRiskBadge.php @@ -0,0 +1,25 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/PolicySnapshotModeBadge.php b/app/Support/Badges/Domains/PolicySnapshotModeBadge.php new file mode 100644 index 0000000..8e56fe2 --- /dev/null +++ b/app/Support/Badges/Domains/PolicySnapshotModeBadge.php @@ -0,0 +1,21 @@ + new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'), + 'metadata_only' => new BadgeSpec('Metadata only', 'warning', 'heroicon-m-exclamation-triangle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php b/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php new file mode 100644 index 0000000..3052b68 --- /dev/null +++ b/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php @@ -0,0 +1,22 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php b/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php new file mode 100644 index 0000000..221d68e --- /dev/null +++ b/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php @@ -0,0 +1,24 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/RestoreResultStatusBadge.php b/app/Support/Badges/Domains/RestoreResultStatusBadge.php new file mode 100644 index 0000000..9a70467 --- /dev/null +++ b/app/Support/Badges/Domains/RestoreResultStatusBadge.php @@ -0,0 +1,26 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/RestoreRunStatusBadge.php b/app/Support/Badges/Domains/RestoreRunStatusBadge.php new file mode 100644 index 0000000..def3ac7 --- /dev/null +++ b/app/Support/Badges/Domains/RestoreRunStatusBadge.php @@ -0,0 +1,33 @@ +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(), + }; + } +} diff --git a/app/Support/Badges/Domains/TenantAppStatusBadge.php b/app/Support/Badges/Domains/TenantAppStatusBadge.php new file mode 100644 index 0000000..7959b03 --- /dev/null +++ b/app/Support/Badges/Domains/TenantAppStatusBadge.php @@ -0,0 +1,24 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/TenantPermissionStatusBadge.php b/app/Support/Badges/Domains/TenantPermissionStatusBadge.php new file mode 100644 index 0000000..5b06968 --- /dev/null +++ b/app/Support/Badges/Domains/TenantPermissionStatusBadge.php @@ -0,0 +1,22 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/TenantRbacStatusBadge.php b/app/Support/Badges/Domains/TenantRbacStatusBadge.php new file mode 100644 index 0000000..befc201 --- /dev/null +++ b/app/Support/Badges/Domains/TenantRbacStatusBadge.php @@ -0,0 +1,25 @@ + 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(), + }; + } +} diff --git a/app/Support/Badges/Domains/TenantStatusBadge.php b/app/Support/Badges/Domains/TenantStatusBadge.php new file mode 100644 index 0000000..6b48320 --- /dev/null +++ b/app/Support/Badges/Domains/TenantStatusBadge.php @@ -0,0 +1,24 @@ + 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(), + }; + } +} diff --git a/resources/views/filament/forms/components/restore-run-checks.blade.php b/resources/views/filament/forms/components/restore-run-checks.blade.php index e7469ba..29211db 100644 --- a/resources/views/filament/forms/components/restore-run-checks.blade.php +++ b/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -22,12 +22,8 @@ } } - $severityColor = static function (?string $severity): string { - return match ($severity) { - 'blocking' => 'danger', - 'warning' => 'warning', - default => 'success', - }; + $severitySpec = static function (?string $severity): \App\Support\Badges\BadgeSpec { + return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity); }; $limitedList = static function (array $items, int $limit = 5): array { @@ -91,8 +87,12 @@ @endif - - {{ ucfirst((string) $severity) }} + @php + $spec = $severitySpec($severity); + @endphp + + + {{ $spec->label }} diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 49676ce..36b86d8 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -18,21 +18,14 @@ @foreach ($foundationItems as $item) @php $decision = $item['decision'] ?? 'mapped_existing'; - $decisionColor = match ($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', - }; + $decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision); @endphp
{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }} - - {{ $decision }} - + + {{ $decisionSpec->label }} +
{{ $item['type'] ?? 'foundation' }} @@ -61,13 +54,16 @@ @endphp
- {{ $item['policy_identifier'] ?? 'Policy' }} -
- @if ($restoreMode === 'preview-only') - - preview-only - - @endif + {{ $item['policy_identifier'] ?? 'Policy' }} +
+ @if ($restoreMode === 'preview-only') + @php + $restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode); + @endphp + + {{ $restoreModeSpec->label }} + + @endif {{ $item['action'] ?? 'action' }} diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 8171bde..250336d 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -53,21 +53,14 @@ @foreach ($foundationItems as $item) @php $decision = $item['decision'] ?? 'mapped_existing'; - $decisionColor = match ($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', - }; + $decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision); @endphp
{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }} - - {{ $decision }} - + + {{ $decisionSpec->label }} +
{{ $item['type'] ?? 'foundation' }} @@ -106,25 +99,20 @@ @php $status = $item['status'] ?? 'unknown'; $restoreMode = $item['restore_mode'] ?? null; - $statusColor = match ($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', - }; + $statusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $status); @endphp -
- @if ($restoreMode === 'preview-only') - - preview-only - - @endif - - {{ $status }} - +
+ @if ($restoreMode === 'preview-only') + @php + $restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode); + @endphp + + {{ $restoreModeSpec->label }} + + @endif + + {{ $statusSpec->label }} +
@@ -165,11 +153,7 @@ @foreach ($assignmentIssues as $outcome) @php $outcomeStatus = $outcome['status'] ?? 'unknown'; - $outcomeColor = match ($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', - }; + $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $outcomeStatus); $assignmentGroupId = $outcome['group_id'] ?? ($outcome['assignment']['target']['groupId'] ?? null); $assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null); @@ -182,9 +166,9 @@
Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
- - {{ $outcomeStatus }} - + + {{ $outcomeSpec->label }} +
@if (! empty($outcome['mapped_group_id'])) @@ -240,20 +224,16 @@ @foreach ($complianceEntries as $outcome) @php $outcomeStatus = $outcome['status'] ?? 'unknown'; - $outcomeColor = match ($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', - }; + $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $outcomeStatus); @endphp
Template {{ $outcome['template_id'] ?? 'unknown' }}
- - {{ $outcomeStatus }} - + + {{ $outcomeSpec->label }} +
@if (! empty($outcome['rule_name']))
diff --git a/specs/059-unified-badges/checklists/requirements.md b/specs/059-unified-badges/checklists/requirements.md new file mode 100644 index 0000000..2dff3c8 --- /dev/null +++ b/specs/059-unified-badges/checklists/requirements.md @@ -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` + diff --git a/specs/059-unified-badges/contracts/badge-semantics.md b/specs/059-unified-badges/contracts/badge-semantics.md new file mode 100644 index 0000000..93a886d --- /dev/null +++ b/specs/059-unified-badges/contracts/badge-semantics.md @@ -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`) + diff --git a/specs/059-unified-badges/contracts/guardrails.md b/specs/059-unified-badges/contracts/guardrails.md new file mode 100644 index 0000000..facae6b --- /dev/null +++ b/specs/059-unified-badges/contracts/guardrails.md @@ -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. + diff --git a/specs/059-unified-badges/data-model.md b/specs/059-unified-badges/data-model.md new file mode 100644 index 0000000..3b62da2 --- /dev/null +++ b/specs/059-unified-badges/data-model.md @@ -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 domain’s 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. + diff --git a/specs/059-unified-badges/plan.md b/specs/059-unified-badges/plan.md new file mode 100644 index 0000000..f89573a --- /dev/null +++ b/specs/059-unified-badges/plan.md @@ -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 + + + +**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. diff --git a/specs/059-unified-badges/quickstart.md b/specs/059-unified-badges/quickstart.md new file mode 100644 index 0000000..baad294 --- /dev/null +++ b/specs/059-unified-badges/quickstart.md @@ -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 don’t appear disabled unless intentionally neutral. + +## Frontend assets +If UI changes don’t show: +- `vendor/bin/sail npm run dev` +- or `vendor/bin/sail npm run build` + diff --git a/specs/059-unified-badges/research.md b/specs/059-unified-badges/research.md new file mode 100644 index 0000000..853091b --- /dev/null +++ b/specs/059-unified-badges/research.md @@ -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. + diff --git a/specs/059-unified-badges/spec.md b/specs/059-unified-badges/spec.md new file mode 100644 index 0000000..f10f0c2 --- /dev/null +++ b/specs/059-unified-badges/spec.md @@ -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)* + + + +### 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. + + + +### 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 domain’s 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)* + + + +### 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. diff --git a/specs/059-unified-badges/tasks.md b/specs/059-unified-badges/tasks.md new file mode 100644 index 0000000..a8c104d --- /dev/null +++ b/specs/059-unified-badges/tasks.md @@ -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` +- T020–T029 [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 (T020–T029) +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. diff --git a/tests/Feature/Guards/NoAdHocStatusBadgesTest.php b/tests/Feature/Guards/NoAdHocStatusBadgesTest.php new file mode 100644 index 0000000..a513b79 --- /dev/null +++ b/tests/Feature/Guards/NoAdHocStatusBadgesTest.php @@ -0,0 +1,115 @@ +color\\s*\\(\\s*(?:fn|function)\\b/'; + $inlineLabelStartPattern = '/->formatStateUsing\\s*\\(\\s*(?:fn|function)\\b/'; + + $forbiddenPlainPatterns = [ + '/\\bBadgeColumn::make\\b/', + '/->colors\\s*\\(/', + ]; + + $lookaheadLines = 25; + + /** @var Collection $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)); +}); diff --git a/tests/Unit/Badges/BackupSetBadgesTest.php b/tests/Unit/Badges/BackupSetBadgesTest.php new file mode 100644 index 0000000..bf1a2fa --- /dev/null +++ b/tests/Unit/Badges/BackupSetBadgesTest.php @@ -0,0 +1,24 @@ +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'); +}); diff --git a/tests/Unit/Badges/BadgeCatalogTest.php b/tests/Unit/Badges/BadgeCatalogTest.php new file mode 100644 index 0000000..e8c9049 --- /dev/null +++ b/tests/Unit/Badges/BadgeCatalogTest.php @@ -0,0 +1,26 @@ +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', + ]); +}); diff --git a/tests/Unit/Badges/FindingBadgesTest.php b/tests/Unit/Badges/FindingBadgesTest.php new file mode 100644 index 0000000..40902b8 --- /dev/null +++ b/tests/Unit/Badges/FindingBadgesTest.php @@ -0,0 +1,30 @@ +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'); +}); diff --git a/tests/Unit/Badges/OperationRunBadgesTest.php b/tests/Unit/Badges/OperationRunBadgesTest.php new file mode 100644 index 0000000..515b6ec --- /dev/null +++ b/tests/Unit/Badges/OperationRunBadgesTest.php @@ -0,0 +1,48 @@ +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'); +}); diff --git a/tests/Unit/Badges/PolicyBadgesTest.php b/tests/Unit/Badges/PolicyBadgesTest.php new file mode 100644 index 0000000..31bac36 --- /dev/null +++ b/tests/Unit/Badges/PolicyBadgesTest.php @@ -0,0 +1,46 @@ +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'); +}); diff --git a/tests/Unit/Badges/RestoreRunBadgesTest.php b/tests/Unit/Badges/RestoreRunBadgesTest.php new file mode 100644 index 0000000..d4d3ab2 --- /dev/null +++ b/tests/Unit/Badges/RestoreRunBadgesTest.php @@ -0,0 +1,56 @@ +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'); +}); diff --git a/tests/Unit/Badges/RestoreUiBadgesTest.php b/tests/Unit/Badges/RestoreUiBadgesTest.php new file mode 100644 index 0000000..b34a43a --- /dev/null +++ b/tests/Unit/Badges/RestoreUiBadgesTest.php @@ -0,0 +1,38 @@ +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'); +}); diff --git a/tests/Unit/Badges/RunStatusBadgesTest.php b/tests/Unit/Badges/RunStatusBadgesTest.php new file mode 100644 index 0000000..57eff1a --- /dev/null +++ b/tests/Unit/Badges/RunStatusBadgesTest.php @@ -0,0 +1,58 @@ +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'); +}); diff --git a/tests/Unit/Badges/TenantBadgesTest.php b/tests/Unit/Badges/TenantBadgesTest.php new file mode 100644 index 0000000..231ac89 --- /dev/null +++ b/tests/Unit/Badges/TenantBadgesTest.php @@ -0,0 +1,66 @@ +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'); +});