Compare commits

...

7 Commits

Author SHA1 Message Date
ce0615a9c1 Spec 182: relocate Laravel platform to apps/platform (#213)
## Summary
- move the Laravel application into `apps/platform` and keep the repository root for orchestration, docs, and tooling
- update the local command model, Sail/Docker wiring, runtime paths, and ignore rules around the new platform location
- add relocation quickstart/contracts plus focused smoke coverage for bootstrap, command model, routes, and runtime behavior

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PlatformRelocation`
- integrated browser smoke validated `/up`, `/`, `/admin`, `/admin/choose-workspace`, and tenant route semantics for `200`, `403`, and `404`

## Remaining Rollout Checks
- validate Dokploy build context and working-directory assumptions against the new `apps/platform` layout
- confirm web, queue, and scheduler processes all start from the expected working directory in staging/production
- verify no legacy volume mounts or asset-publish paths still point at the old root-level `public/` or `storage/` locations

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #213
2026-04-08 08:40:47 +00:00
6f8eb28ca2 feat: add tenant backup health signals (#212)
## Summary
- add the Spec 180 tenant backup-health resolver and value objects to derive absent, stale, degraded, healthy, and schedule-follow-up posture from existing backup and schedule truth
- surface backup posture and reason-driven drillthroughs in the tenant dashboard and preserve continuity on backup-set and backup-schedule destinations
- add deterministic local/testing browser-fixture seeding plus a local fixture-login helper for the blocked drillthrough `403` scenario, along with the related spec artifacts and focused regression coverage

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Auth/BackupHealthBrowserFixtureLoginTest.php tests/Feature/Console/TenantpilotSeedBackupHealthBrowserFixtureCommandTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php tests/Feature/Filament/TenantDashboardTenantScopeTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Feature/Filament/BackupSetListContinuityTest.php tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php tests/Feature/Auth/BackupHealthBrowserFixtureLoginTest.php tests/Feature/Console/TenantpilotSeedBackupHealthBrowserFixtureCommandTest.php`

## Notes
- Filament v5 / Livewire v4 compliant; no panel-provider change was needed, so `bootstrap/providers.php` remains unchanged
- no new globally searchable resource was introduced, so global-search behavior is unchanged
- no new destructive action was added; existing destructive actions and confirmation behavior remain unchanged
- no new asset registration was added; the existing deploy-time `php artisan filament:assets` step remains sufficient
- the local fixture login helper route is limited to `local` and `testing` environments
- the focused and broader Spec 180 packs are green; the full suite was not rerun after these changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #212
2026-04-07 21:35:58 +00:00
e840007127 feat: add backup quality truth surfaces (#211)
## Summary
- add a shared backup-quality resolver and summary model for backup sets, backup items, policy versions, and restore selection
- surface backup-quality truth across Filament backup-set, policy-version, and restore-wizard entry points
- add focused Pest coverage and the full Spec Kit artifact set for spec 176

## Testing
- focused backup-quality verification and integrated-browser smoke coverage were completed during implementation
- degraded browser smoke path was validated with temporary seeded records and then cleaned up again
- the workspace already has a prior `vendor/bin/sail artisan test --compact` run exiting non-zero; that full-suite failure was not reworked as part of this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #211
2026-04-07 11:39:40 +00:00
a107e7e41b feat: restore safety integrity and queue slide-over (#210)
## Summary
- add the Spec 181 restore-safety layer with scope fingerprinting, preview/check integrity states, execution safety snapshots, result attention, and operator-facing copy across the wizard, restore detail, and canonical operation detail
- add focused unit and feature coverage for restore-safety assessment, result attention, and restore-linked operation detail
- switch the finding exceptions queue `Inspect exception` action to a native Filament slide-over while preserving query-param-backed inline summary behavior

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueTest.php tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php tests/Feature/Operations/RestoreLinkedOperationDetailTest.php tests/Unit/Support/RestoreSafety`

## Notes
- Spec 181 checklist is complete (`specs/181-restore-safety-integrity/checklists/requirements.md`)
- the branch still has unchecked follow-up tasks in `specs/181-restore-safety-integrity/tasks.md`: `T012`, `T018`, `T019`, `T023`, `T025`, `T029`, `T032`, `T033`, `T041`, `T042`, `T043`, `T044`
- Filament v5 / Livewire v4 compliance is preserved, no panel provider registration changes were made, no global-search behavior was added, destructive actions remain confirmation-gated, and no new Filament assets were introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #210
2026-04-06 23:37:14 +00:00
1142d283eb feat: Spec 178 — Operations Lifecycle Alignment & Cross-Surface Truth Consistency (#209)
## Spec 178 — Operations Lifecycle Alignment & Cross-Surface Truth Consistency

Härtet die Run-Lifecycle-Wahrheit und Cross-Surface-Konsistenz über alle zentralen Operator-Flächen hinweg.

### Kern-Änderungen

**Lifecycle Truth Alignment**
- Einheitliche stale/stuck-Semantik zwischen Tenant-, Workspace-, Admin- und System-Surfaces
- `OperationRunFreshnessState` wird konsistent über alle Widgets und Seiten propagiert
- Gemeinsame Problem-Klassen-Trennung: `terminal_follow_up` vs. `active_stale_attention`

**BulkOperationProgress Freshness**
- Overlay zeigt nur noch `healthyActive()` Runs statt alle aktiven Runs
- Likely-stale Runs halten das Polling nicht mehr künstlich aktiv
- Terminal Runs verschwinden zeitnah aus dem Progress-Overlay

**Decision Zone im Run Detail**
- Stale/reconciled Attention in der primären Decision-Hierarchie
- Klare Antworten: aktiv? stale? reconciled? nächster Schritt?
- Artifact-reiche Runs behalten Lifecycle-Truth vor Deep-Diagnostics

**Cross-Surface Link-Continuity**
- Dashboard → Operations Hub → Run Detail erzählen dieselbe Geschichte
- Notifications referenzieren korrekte Problem-Klasse
- Workspace/Tenant-Attention verlinken problemklassengerecht

**System-Plane Fixes**
- `/system/ops/failures` 500-Error behoben (panel-sichere Artifact-URLs)
- System-Stuck/Failures zeigen reconciled stale lineage

### Weitere Fixes
- Inventory auth guard bereinigt (Gate statt ad-hoc Facades)
- Browser-Smoke-Tests stabilisiert (DOM-Assertions statt fragile Klicks)
- Test-Assertion-Drift für Verification/Lifecycle-Texte korrigiert

### Test-Ergebnis
Full Suite: **3269 passed**, 8 skipped, 0 failed

### Spec-Artefakte
- `specs/178-ops-truth-alignment/spec.md`
- `specs/178-ops-truth-alignment/plan.md`
- `specs/178-ops-truth-alignment/tasks.md`
- `specs/178-ops-truth-alignment/research.md`
- `specs/178-ops-truth-alignment/data-model.md`
- `specs/178-ops-truth-alignment/quickstart.md`
- `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #209
2026-04-05 22:42:24 +00:00
f52d52540c feat: implement inventory coverage truth (#208)
## Summary
- implement Spec 177 inventory coverage truth across resolver, badges, KPIs, coverage page, and operation run detail surfaces
- add repo-native spec artifacts for the feature under `specs/177-inventory-coverage-truth`
- add unit, feature, and browser coverage for truth derivation, continuity, and inventory item filter/pagination smoke paths

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- focused Spec 177 browser smoke file passed with 2 tests / 57 assertions
- extended inventory-focused test pack passed with 52 tests / 434 assertions

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #208
2026-04-05 12:35:20 +00:00
dc46c4fa58 feat: complete provider truth cleanup (#207)
## Summary
- implement Spec 179 to make tenant lifecycle, provider consent, and provider verification the primary truth axes on the targeted Filament surfaces
- demote legacy tenant app status and legacy provider status and health to diagnostic-only roles, add centralized badge mappings for provider consent and verification, and keep provider connections excluded from global search
- add the full Spec 179 artifact set under `specs/179-provider-truth-cleanup/` plus focused Pest coverage for tenant truth cleanup, provider truth cleanup, RBAC, discovery safety, and badge semantics
- fix the numeric out-of-scope tenant route regression so inaccessible `/admin/tenants/{id}` paths return `404 Not Found` instead of `500`

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php`

## Manual validation
- integrated-browser smoke on `/admin/tenants`, tenant detail, `/admin/provider-connections`, provider detail, and provider edit
- verified out-of-scope tenant and provider URLs return `404 Not Found` with the current session

## Notes
- branch: `179-provider-truth-cleanup`
- commit: `e54c6632`
- target: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #207
2026-04-05 00:48:31 +00:00
2447 changed files with 24632 additions and 2598 deletions

View File

@ -1,7 +1,9 @@
node_modules/ node_modules/
apps/platform/node_modules/
dist/ dist/
build/ build/
vendor/ vendor/
apps/platform/vendor/
coverage/ coverage/
.git/ .git/
.DS_Store .DS_Store
@ -18,12 +20,19 @@ Dockerfile*
*.tmp *.tmp
*.swp *.swp
public/build/ public/build/
apps/platform/public/build/
public/hot/ public/hot/
apps/platform/public/hot/
public/storage/ public/storage/
apps/platform/public/storage/
storage/framework/ storage/framework/
apps/platform/storage/framework/
storage/logs/ storage/logs/
apps/platform/storage/logs/
storage/debugbar/ storage/debugbar/
apps/platform/storage/debugbar/
storage/*.key storage/*.key
apps/platform/storage/*.key
/references/ /references/
.idea/ .idea/
.vscode/ .vscode/

View File

@ -2,6 +2,12 @@ # TenantAtlas Development Guidelines
Auto-generated from all feature plans. Last updated: 2025-12-22 Auto-generated from all feature plans. Last updated: 2025-12-22
## Relocation override
- The authoritative Laravel application root is `apps/platform`.
- Human-facing commands should use `cd apps/platform && ...`.
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
## Active Technologies ## Active Technologies
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations) - PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations) - PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
@ -129,6 +135,20 @@ ## Active Technologies
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust) - PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention)
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention) - PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials (179-provider-truth-cleanup)
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment)
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers (181-restore-safety-integrity)
- PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned (181-restore-safety-integrity)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers (176-backup-quality-truth)
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned (176-backup-quality-truth)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers (180-tenant-backup-health)
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned (180-tenant-backup-health)
- PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose (182-platform-relocation)
- PostgreSQL, Redis, filesystem storage under the Laravel app `storage/` tree, plus existing Vite build artifacts in `public/build`; no new database persistence planned (182-platform-relocation)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -148,8 +168,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes - 182-platform-relocation: Added PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose
- 174-evidence-freshness-publication-trust: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages - 180-tenant-backup-health: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers
- 173-tenant-dashboard-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page - 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -40,7 +40,7 @@ ## 3) Panel setup defaults
- Assets policy: - Assets policy:
- Panel-only assets: register via panel config. - Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`. - Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`. - Deployment must include `cd apps/platform && php artisan filament:assets`.
Sources: Sources:
- https://filamentphp.com/docs/5.x/panel-configuration - https://filamentphp.com/docs/5.x/panel-configuration
@ -254,7 +254,7 @@ ## Testing
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions” - Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
=== foundation rules === === foundation rules ===
@ -292,7 +292,7 @@ ## Application Structure & Architecture
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
## Replies ## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details. - Be concise in your explanations - focus on what's important rather than explaining obvious details.
@ -372,28 +372,29 @@ ## Enums
## Laravel Sail ## Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - The canonical application working directory is `apps/platform`. Repo-root launchers such as MCP or VS Code tasks may use `./scripts/platform-sail`, but that helper is compatibility-only.
- Open the application in the browser by running `vendor/bin/sail open`. - Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples: - Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
- Run Artisan Commands: `vendor/bin/sail artisan migrate` - Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
- Install Composer packages: `vendor/bin/sail composer install` - Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
- Execute Node commands: `vendor/bin/sail npm run dev` - Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
## Do Things the Laravel Way ## Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database ### Database
@ -404,7 +405,7 @@ ### Database
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
@ -428,10 +429,10 @@ ### Configuration
### Testing ### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -460,7 +461,7 @@ ### Models
## Livewire ## Livewire
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests. - Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components. - Use the `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it. - State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions. - All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
@ -504,8 +505,8 @@ ## Testing Livewire
## Laravel Pint Code Formatter ## Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. - Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test`, simply run `cd apps/platform && ./vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
@ -514,7 +515,7 @@ ### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test. - If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests ### Pest Tests
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`. - All tests must be written using Pest. Use `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. - You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths. - Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories. - Tests live in the `tests/Feature` and `tests/Unit` directories.
@ -527,9 +528,9 @@ ### Pest Tests
### Running Tests ### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits. - Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test --compact`. - To run all tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`. - To run all tests in a file: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions ### Pest Assertions

13
.gitignore vendored
View File

@ -15,19 +15,30 @@
/.zed /.zed
/auth.json /auth.json
/node_modules /node_modules
/apps/platform/node_modules
dist/ dist/
build/ build/
coverage/ coverage/
/public/build /public/build
/apps/platform/public/build
/public/hot /public/hot
/apps/platform/public/hot
/public/storage /public/storage
/apps/platform/public/storage
/storage/*.key /storage/*.key
/apps/platform/storage/*.key
/storage/pail /storage/pail
/apps/platform/storage/pail
/storage/framework /storage/framework
/apps/platform/storage/framework
/storage/logs /storage/logs
/apps/platform/storage/logs
/storage/debugbar /storage/debugbar
/apps/platform/storage/debugbar
/vendor /vendor
/apps/platform/vendor
/bootstrap/cache /bootstrap/cache
/apps/platform/bootstrap/cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
@ -35,3 +46,5 @@ Thumbs.db
/tests/Browser/Screenshots /tests/Browser/Screenshots
*.tmp *.tmp
*.swp *.swp
/apps/platform/.env
/apps/platform/.env.*

View File

@ -1,8 +1,11 @@
dist/ dist/
build/ build/
public/build/ public/build/
apps/platform/public/build/
node_modules/ node_modules/
apps/platform/node_modules/
vendor/ vendor/
apps/platform/vendor/
*.log *.log
.env .env
.env.* .env.*

View File

@ -2,12 +2,19 @@ node_modules/
dist/ dist/
build/ build/
public/build/ public/build/
apps/platform/public/build/
public/hot/ public/hot/
apps/platform/public/hot/
public/storage/ public/storage/
apps/platform/public/storage/
coverage/ coverage/
vendor/ vendor/
apps/platform/vendor/
apps/platform/node_modules/
storage/ storage/
apps/platform/storage/
bootstrap/cache/ bootstrap/cache/
apps/platform/bootstrap/cache/
package-lock.json package-lock.json
yarn.lock yarn.lock
pnpm-lock.yaml pnpm-lock.yaml

View File

@ -1,32 +1,20 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.14.0 -> 2.0.0 - Version change: 2.0.0 -> 2.1.0
- Modified principles: - Modified principles:
- Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract - UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
- Filament UI - Layout & Information Architecture Standards (UX-001) -> Operator-Facing UI/UX Constitution v1 / Filament UI - Layout & Information Architecture Standards (UX-001) with cross-reference to new HDR-001
- Operator-facing UI Naming Standards (UI-NAMING-001) -> Operator-Facing UI/UX Constitution v1 / Operator-facing UI Naming Standards (UI-NAMING-001)
- Operator Surface Principles (OPSURF-001) -> Operator-Facing UI/UX Constitution v1 / Operator Surface Principles (OPSURF-001)
- Spec Scope Fields (SCOPE-002) -> Operator-Facing UI/UX Constitution v1 / Spec Scope Fields (SCOPE-002)
- Added sections: - Added sections:
- Operator-Facing UI/UX Constitution v1 (UI-CONST-001) - Header Action Discipline & Contextual Navigation (HDR-001)
- Surface Taxonomy (UI-SURF-001)
- Hard Rules (UI-HARD-001)
- Exception Model (UI-EX-001)
- Enforcement Model (UI-REVIEW-001)
- Immediate Retrofit Priorities
- Appendix A - One-page Condensed Constitution
- Appendix B - Feature Review Checklist
- Appendix C - Red Flags for Future PRs
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/memory/constitution.md - ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
- ✅ .specify/templates/tasks-template.md - ⚠ .specify/templates/spec-template.md (no changes needed; existing
- ✅ docs/product/principles.md UI/UX Surface Classification and Operator Surface Contract tables already
- ✅ docs/product/standards/README.md cover header action placement implicitly)
- ✅ docs/HANDOVER.md
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo - N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs: - Follow-up TODOs:
@ -535,7 +523,7 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell. - When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
Actions and flows Actions and flows
- Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups. - Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation. - Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
- Destructive actions remain non-primary and confirmed. - Destructive actions remain non-primary and confirmed.
@ -548,6 +536,121 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available. - Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not. - A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
#### Header Action Discipline & Contextual Navigation (HDR-001)
Goal: record and detail pages MUST be comprehensible within seconds.
Header actions are reserved for the primary workflow of the current page
and MUST NOT become a dumping ground for every available action or
navigation jump.
##### Core rule
Header actions MUST contain only workflow-critical actions of the
currently displayed record. Pure navigation, relational jumps, and
contextual references do not belong in the header; they belong directly
at the affected field, status indicator, or relation.
##### Maximum one primary visible header action
- Each record/detail page MUST expose at most one clearly prioritized
primary visible header action.
- That action MUST represent the most obvious next operator step on
exactly this page.
##### Navigation does not belong in headers
- Actions such as "Open finding", "Open queue", "View related run",
"Open tenant", or similar jumps are navigation actions, not primary
object actions.
- They MUST be placed as contextual navigation at fields, badges,
relation entries, or status displays — never in the header.
##### Destructive or governance-changing actions require friction
- Actions with operational, security-relevant, or governance-changing
effect MUST NOT stand at the same visual level as the primary action.
- They MUST either:
- be rendered as a clearly separated danger action, or
- be placed in an Action Group / More Actions.
- They MUST always require explicit confirmation
(`->requiresConfirmation()`).
- If an action changes governance truth, compliance status, risk
acceptance, exception validity, or equivalent system truths,
additional friction is mandatory (e.g., typed confirmation, reason
field, or staged flow).
##### Rare secondary actions belong in an Action Group
- Actions that are not part of the expected core workflow of the page
or are only occasionally needed MUST NOT appear as equally weighted
visible header buttons.
- They MUST be placed in an Action Group.
##### Header clarity over implementation convenience
- The fact that a framework makes header actions easy to add is not a
reason to place actions there.
- Information architecture, scanability, and operator clarity take
precedence over implementation convenience.
##### 5-second scan rule
Every record/detail page MUST pass the 5-second scan rule:
1. The operator instantly recognizes where they are.
2. The operator instantly sees the status of the object.
3. The operator instantly identifies the one central next action.
4. The operator immediately understands where secondary or dangerous
actions live.
If multiple equally weighted header buttons degrade this readability,
it is a constitution violation.
##### Placement rules
Allowed in the header:
- One primary workflow action.
- Optionally one clearly justified secondary action.
- Rare or administrative actions only when grouped.
- Critical/destructive actions only when separated and with friction.
Forbidden in the header:
- Pure navigation to related objects.
- Relational jumps without immediate workflow relevance.
- Collections of technically available standard actions.
- Multiple equally weighted buttons without clear prioritization.
##### Preferred pattern
| Slot | Placement |
|---|---|
| Primary visible | Exactly 1 |
| Danger | Separated or grouped, never casual beside Primary |
| Navigation | Inline at context (field, badge, relation) |
| Rare actions | More / Action Group |
##### Binding decision — Exception / Approval surfaces
For exception detail pages specifically:
- **Renew exception** MAY appear as the primary visible header action.
- **Revoke exception** is a governance-changing danger action and MUST
require friction (separated + confirmation).
- **Open finding** MUST be placed as a link at the Finding field, not
in the header.
- **Open approval queue** MUST be placed as a contextual link at
approval / status context, not in the header.
##### Reviewer heuristics
A page violates HDR-001 if any of the following are true:
- Multiple equally weighted header actions without clear workflow
priority.
- Pure navigation buttons in the header.
- Danger actions beside normal actions without clear separation.
- Rarely used administrative actions as visible standard buttons.
- The header resembles an action stockpile instead of a focused
workflow entry point.
#### Operator-facing UI Naming Standards (UI-NAMING-001) #### Operator-facing UI Naming Standards (UI-NAMING-001)
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary. Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
@ -672,6 +775,7 @@ #### Appendix A - One-page Condensed Constitution
- Standard lists stay scanable. - Standard lists stay scanable.
- Exceptions are catalogued, justified, and tested. - Exceptions are catalogued, justified, and tested.
- Features with ambiguous interaction semantics do not ship. - Features with ambiguous interaction semantics do not ship.
- Header actions on record/detail pages expose at most one primary action; navigation belongs at context, not in the header.
#### Appendix B - Feature Review Checklist #### Appendix B - Feature Review Checklist
@ -690,6 +794,9 @@ #### Appendix B - Feature Review Checklist
- Critical truth is visible. - Critical truth is visible.
- Scanability is preserved. - Scanability is preserved.
- Exceptions are documented and tested. - Exceptions are documented and tested.
- Header passes the 5-second scan rule (HDR-001).
- No pure navigation in the header.
- Governance-changing actions have extra friction.
#### Appendix C - Red Flags for Future PRs #### Appendix C - Red Flags for Future PRs
@ -704,6 +811,9 @@ #### Appendix C - Red Flags for Future PRs
- Queue surfaces throw the operator out of context through row click. - Queue surfaces throw the operator out of context through row click.
- Critical health or operability truth is hidden by default. - Critical health or operability truth is hidden by default.
- A contract claims conformance while the rendered UI behaves differently. - A contract claims conformance while the rendered UI behaves differently.
- Header has multiple equally weighted buttons without clear prioritization.
- "Open X" navigation links placed in the header instead of at the related field.
- Governance-changing actions sit casually beside the primary action without friction.
### Data Minimization & Safe Logging ### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`. - Inventory MUST store only metadata + whitelisted `meta_jsonb`.
@ -787,4 +897,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28 **Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07

View File

@ -70,7 +70,8 @@ ## Constitution Check
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions - Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions - Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency - Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
## Project Structure ## Project Structure
### Documentation (this feature) ### Documentation (this feature)

View File

@ -72,6 +72,7 @@ # Tasks: [FEATURE NAME]
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001, - ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header, - ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
- capping header actions to max 1 primary + 1 secondary (rest grouped), - capping header actions to max 1 primary + 1 secondary (rest grouped),
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available, - using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied. - OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001), **Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),

View File

@ -318,12 +318,13 @@ ## Security
## Commands ## Commands
### Sail (preferred locally) ### Sail (preferred locally)
- `./vendor/bin/sail up -d` - `cd apps/platform && ./vendor/bin/sail up -d`
- `./vendor/bin/sail down` - `cd apps/platform && ./vendor/bin/sail down`
- `./vendor/bin/sail composer install` - `cd apps/platform && ./vendor/bin/sail composer install`
- `./vendor/bin/sail artisan migrate` - `cd apps/platform && ./vendor/bin/sail artisan migrate`
- `./vendor/bin/sail artisan test` - `cd apps/platform && ./vendor/bin/sail artisan test`
- `./vendor/bin/sail artisan` (general) - `cd apps/platform && ./vendor/bin/sail artisan` (general)
- Root helper for tooling only: `./scripts/platform-sail ...`
### Drizzle (local DB tooling, if configured) ### Drizzle (local DB tooling, if configured)
- Use only for local/dev workflows. - Use only for local/dev workflows.
@ -335,10 +336,10 @@ ### Drizzle (local DB tooling, if configured)
(Agents should confirm the exact script names in `package.json` before suggesting them.) (Agents should confirm the exact script names in `package.json` before suggesting them.)
### Non-Docker fallback (only if needed) ### Non-Docker fallback (only if needed)
- `composer install` - `cd apps/platform && composer install`
- `php artisan serve` - `cd apps/platform && php artisan serve`
- `php artisan migrate` - `cd apps/platform && php artisan migrate`
- `php artisan test` - `cd apps/platform && php artisan test`
### Frontend/assets/tooling (if present) ### Frontend/assets/tooling (if present)
- `pnpm install` - `pnpm install`
@ -352,11 +353,11 @@ ## Where to look first
- `.specify/` - `.specify/`
- `AGENTS.md` - `AGENTS.md`
- `README.md` - `README.md`
- `app/` - `apps/platform/app/`
- `database/` - `apps/platform/database/`
- `routes/` - `apps/platform/routes/`
- `resources/` - `apps/platform/resources/`
- `config/` - `apps/platform/config/`
--- ---
@ -433,7 +434,7 @@ ## 3) Panel setup defaults
- Assets policy: - Assets policy:
- Panel-only assets: register via panel config. - Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`. - Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`. - Deployment must include `cd apps/platform && php artisan filament:assets`.
Sources: Sources:
- https://filamentphp.com/docs/5.x/panel-configuration - https://filamentphp.com/docs/5.x/panel-configuration
@ -670,7 +671,7 @@ ## Testing
## Deployment / Ops ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
=== foundation rules === === foundation rules ===
@ -720,7 +721,7 @@ ## Application Structure & Architecture
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
## Documentation Files ## Documentation Files
@ -812,28 +813,28 @@ ## PHPDoc Blocks
# Laravel Sail # Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`. - Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate` - Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install` - Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev` - Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
=== tests rules === === tests rules ===
# Test Enforcement # Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
# Do Things the Laravel Way # Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database ## Database
@ -846,7 +847,7 @@ ## Database
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
@ -877,11 +878,11 @@ ## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error ## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -912,15 +913,15 @@ ### Models
# Laravel Pint Code Formatter # Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. - You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues. - Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`. - This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`. - Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval. - Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.

View File

@ -156,12 +156,13 @@ ## Security
## Commands ## Commands
### Sail (preferred locally) ### Sail (preferred locally)
- `./vendor/bin/sail up -d` - `cd apps/platform && ./vendor/bin/sail up -d`
- `./vendor/bin/sail down` - `cd apps/platform && ./vendor/bin/sail down`
- `./vendor/bin/sail composer install` - `cd apps/platform && ./vendor/bin/sail composer install`
- `./vendor/bin/sail artisan migrate` - `cd apps/platform && ./vendor/bin/sail artisan migrate`
- `./vendor/bin/sail artisan test` - `cd apps/platform && ./vendor/bin/sail artisan test`
- `./vendor/bin/sail artisan` (general) - `cd apps/platform && ./vendor/bin/sail artisan` (general)
- Root helper for tooling only: `./scripts/platform-sail ...`
### Drizzle (local DB tooling, if configured) ### Drizzle (local DB tooling, if configured)
- Use only for local/dev workflows. - Use only for local/dev workflows.
@ -173,10 +174,10 @@ ### Drizzle (local DB tooling, if configured)
(Agents should confirm the exact script names in `package.json` before suggesting them.) (Agents should confirm the exact script names in `package.json` before suggesting them.)
### Non-Docker fallback (only if needed) ### Non-Docker fallback (only if needed)
- `composer install` - `cd apps/platform && composer install`
- `php artisan serve` - `cd apps/platform && php artisan serve`
- `php artisan migrate` - `cd apps/platform && php artisan migrate`
- `php artisan test` - `cd apps/platform && php artisan test`
### Frontend/assets/tooling (if present) ### Frontend/assets/tooling (if present)
- `pnpm install` - `pnpm install`
@ -190,11 +191,11 @@ ## Where to look first
- `.specify/` - `.specify/`
- `AGENTS.md` - `AGENTS.md`
- `README.md` - `README.md`
- `app/` - `apps/platform/app/`
- `database/` - `apps/platform/database/`
- `routes/` - `apps/platform/routes/`
- `resources/` - `apps/platform/resources/`
- `config/` - `apps/platform/config/`
--- ---
@ -271,7 +272,7 @@ ## 3) Panel setup defaults
- Assets policy: - Assets policy:
- Panel-only assets: register via panel config. - Panel-only assets: register via panel config.
- Shared/plugin assets: register via `FilamentAsset::register()`. - Shared/plugin assets: register via `FilamentAsset::register()`.
- Deployment must include `php artisan filament:assets`. - Deployment must include `cd apps/platform && php artisan filament:assets`.
Sources: Sources:
- https://filamentphp.com/docs/5.x/panel-configuration - https://filamentphp.com/docs/5.x/panel-configuration
@ -508,7 +509,7 @@ ## Testing
## Deployment / Ops ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
=== foundation rules === === foundation rules ===
@ -558,7 +559,7 @@ ## Application Structure & Architecture
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
## Documentation Files ## Documentation Files
@ -650,28 +651,28 @@ ## PHPDoc Blocks
# Laravel Sail # Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`. - Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate` - Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install` - Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev` - Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
=== tests rules === === tests rules ===
# Test Enforcement # Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
# Do Things the Laravel Way # Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. - Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
## Database ## Database
@ -684,7 +685,7 @@ ## Database
### Model Creation ### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
@ -715,11 +716,11 @@ ## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error ## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -750,15 +751,15 @@ ### Models
# Laravel Pint Code Formatter # Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. - You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues. - Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`. - This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`. - Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval. - Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.

View File

@ -9,11 +9,18 @@
## TenantPilot setup ## TenantPilot setup
- Local dev (Sail-first): - Platform app root: `apps/platform`
- Start stack: `./vendor/bin/sail up -d` - Repo-root ownership: specs, docs, scripts, editor config, agent config, orchestration, and `docker-compose.yml`
- Init DB: `./vendor/bin/sail artisan migrate --seed` - App-root ownership: Laravel runtime, tests, Vite assets, public entrypoints, `composer.json`, `package.json`, `drizzle.config.ts`, and app-local `.env*`
- Tests: `./vendor/bin/sail artisan test` - Local dev (Sail-first, canonical workflow):
- Policy sync: `./vendor/bin/sail artisan intune:sync-policies` - Install: `cd apps/platform && composer install`
- Env bootstrap: `cd apps/platform && cp .env.example .env`
- Start stack: `cd apps/platform && ./vendor/bin/sail up -d`
- Generate app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
- Init DB: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
- Tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
- Policy sync: `cd apps/platform && ./vendor/bin/sail artisan intune:sync-policies`
- Compatibility helper for tooling that cannot set a nested working directory: `./scripts/platform-sail ...`
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`). - Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
- Microsoft Graph (Intune) env vars: - Microsoft Graph (Intune) env vars:
- `GRAPH_TENANT_ID` - `GRAPH_TENANT_ID`
@ -25,10 +32,17 @@ ## TenantPilot setup
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All` - **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
- Deployment (Dokploy, staging → production): - Deployment (Dokploy, staging → production):
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline). - Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
- Run application commands from `apps/platform`, including `php artisan filament:assets`.
- Run migrations on staging first, validate backup/restore flows, then promote to production. - Run migrations on staging first, validate backup/restore flows, then promote to production.
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy. - Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
- Keep secrets/env in Dokploy, never in code. - Keep secrets/env in Dokploy, never in code.
## Platform relocation rollout notes
- Open branches that still touch legacy root app paths should merge `dev` first, then remap file moves from `app/`, `bootstrap/`, `config/`, `database/`, `lang/`, `public/`, `resources/`, `routes/`, `storage/`, and `tests/` into `apps/platform/...`.
- Keep using merge-based catch-up on shared feature branches; do not rebase long-lived shared branches just to absorb the relocation.
- VS Code tasks and MCP launchers now delegate through `./scripts/platform-sail` from the repo root. Human-facing docs remain `apps/platform`-first.
## Bulk operations (Feature 005) ## Bulk operations (Feature 005)
- Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs). - Bulk actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
@ -39,8 +53,23 @@ ### Troubleshooting
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect). - **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`. - Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`. - Check worker status/logs: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`.
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container. - **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
- **Moved app but old commands still fail** usually means the command is still being run from repo root. Switch to `cd apps/platform && ...` or use `./scripts/platform-sail ...` only for tooling that cannot set `cwd`.
## Rollback checklist
1. Revert the relocation commit or merge on your feature branch instead of hard-resetting shared history.
2. Preserve any local app env overrides before switching commits: `cp apps/platform/.env /tmp/tenantatlas.platform.env.backup` if needed.
3. Stop local containers and clean generated artifacts: `cd apps/platform && ./vendor/bin/sail down -v`, then remove `apps/platform/vendor`, `apps/platform/node_modules`, `apps/platform/public/build`, and `apps/platform/public/hot` if they need a clean rebuild.
4. After rollback, restore the matching env file for the restored topology and rerun the documented setup flow for that commit.
5. Notify owners of open feature branches that the topology changed so they can remap outstanding work before the next merge from `dev`.
## Deployment unknowns
- Dokploy build context for a repo-root compose file plus an app-root Laravel runtime still needs staging confirmation.
- Production web, queue, and scheduler working directories must be verified explicitly after the move; do not assume repo root and app root behave interchangeably.
- Any Dokploy volume mounts or storage persistence paths that previously targeted repo-root `storage/` must be reviewed against `apps/platform/storage/`.
### Configuration ### Configuration
@ -64,7 +93,7 @@ ## Graph Contract Registry & Drift Guard
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim. - Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
- Derived @odata.type values within the family are accepted for preview/restore routing. - Derived @odata.type values within the family are accepted for preview/restore routing.
- Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings. - Capability fallback: on 400s related to select/expand, retries without those clauses and surfaces warnings.
- Drift check: `php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional). - Drift check: `cd apps/platform && php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows. - If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
## Policy Settings Display ## Policy Settings Display

View File

@ -1,146 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class DashboardKpis extends StatsOverviewWidget
{
protected int|string|array $columnSpan = 'full';
protected function getPollingInterval(): ?string
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
}
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return $this->emptyStats();
}
$tenantId = (int) $tenant->getKey();
$openDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId)
->openDrift()
->count();
$highSeverityActiveFindings = (int) Finding::query()
->where('tenant_id', $tenantId)
->highSeverityActive()
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->healthyActive()
->count();
$followUpRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp()
->count();
$openDriftUrl = $openDriftFindings > 0
? $this->findingsUrl($tenant, [
'tab' => 'needs_action',
'finding_type' => Finding::FINDING_TYPE_DRIFT,
])
: null;
$highSeverityUrl = $highSeverityActiveFindings > 0
? $this->findingsUrl($tenant, [
'tab' => 'needs_action',
'high_severity' => 1,
])
: null;
$findingsHelperText = $this->findingsHelperText($tenant);
return [
Stat::make('Open drift findings', $openDriftFindings)
->description($openDriftUrl === null && $openDriftFindings > 0
? $findingsHelperText
: 'active drift workflow items')
->color($openDriftFindings > 0 ? 'warning' : 'gray')
->url($openDriftUrl),
Stat::make('High severity active findings', $highSeverityActiveFindings)
->description($highSeverityUrl === null && $highSeverityActiveFindings > 0
? $findingsHelperText
: 'high or critical findings needing review')
->color($highSeverityActiveFindings > 0 ? 'danger' : 'gray')
->url($highSeverityUrl),
Stat::make('Active operations', $activeRuns)
->description('healthy queued or running tenant work')
->color($activeRuns > 0 ? 'info' : 'gray')
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
Stat::make('Operations needing follow-up', $followUpRuns)
->description('failed, warning, or stalled runs')
->color($followUpRuns > 0 ? 'danger' : 'gray')
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
];
}
/**
* @return array<Stat>
*/
private function emptyStats(): array
{
return [
Stat::make('Open drift findings', 0),
Stat::make('High severity active findings', 0),
Stat::make('Active operations', 0),
Stat::make('Operations needing follow-up', 0),
];
}
/**
* @param array<string, mixed> $parameters
*/
private function findingsUrl(Tenant $tenant, array $parameters): ?string
{
if (! $this->canOpenFindings($tenant)) {
return null;
}
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
}
private function findingsHelperText(Tenant $tenant): string
{
return $this->canOpenFindings($tenant)
? 'Open findings'
: UiTooltips::INSUFFICIENT_PERMISSION;
}
private function canOpenFindings(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
}
}

View File

@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class NeedsAttention extends Widget
{
protected string $view = 'filament.widgets.dashboard.needs-attention';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'pollingInterval' => null,
'items' => [],
'healthyChecks' => [],
];
}
$tenantId = (int) $tenant->getKey();
$aggregate = $this->governanceAggregate($tenant);
$compareAssessment = $aggregate->summaryAssessment;
$items = [];
$overdueOpenCount = $aggregate->overdueOpenFindingsCount;
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
$operationsFollowUpCount = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp()
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->healthyActive()
->count();
if ($lapsedGovernanceCount > 0) {
$items[] = [
'key' => 'lapsed_governance',
'title' => 'Lapsed accepted-risk governance',
'body' => "{$lapsedGovernanceCount} accepted-risk finding(s) no longer have valid supporting governance.",
'badge' => 'Governance',
'badgeColor' => 'danger',
...$this->findingsAction(
$tenant,
'Open findings',
[
'tab' => 'risk_accepted',
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
],
),
];
}
if ($overdueOpenCount > 0) {
$items[] = [
'key' => 'overdue_findings',
'title' => 'Overdue findings',
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
'badge' => 'Findings',
'badgeColor' => 'danger',
...$this->findingsAction(
$tenant,
'Open findings',
['tab' => 'overdue'],
),
];
}
if ($expiringGovernanceCount > 0) {
$items[] = [
'key' => 'expiring_governance',
'title' => 'Expiring accepted-risk governance',
'body' => "{$expiringGovernanceCount} accepted-risk finding(s) need governance review soon.",
'badge' => 'Governance',
'badgeColor' => 'warning',
...$this->findingsAction(
$tenant,
'Open findings',
[
'tab' => 'risk_accepted',
'governance_validity' => FindingException::VALIDITY_EXPIRING,
],
),
];
}
if ($highSeverityCount > 0) {
$items[] = [
'key' => 'high_severity_active_findings',
'title' => 'High severity active findings',
'body' => "{$highSeverityCount} high or critical finding(s) are still active.",
'badge' => 'Findings',
'badgeColor' => 'danger',
...$this->findingsAction(
$tenant,
'Open findings',
[
'tab' => 'needs_action',
'high_severity' => 1,
],
),
];
}
if ($compareAssessment->stateFamily !== 'positive') {
$items[] = [
'key' => 'baseline_compare_posture',
'title' => 'Baseline compare posture',
'body' => $compareAssessment->headline,
'supportingMessage' => $compareAssessment->supportingMessage,
'badge' => 'Baseline',
'badgeColor' => $compareAssessment->tone,
'actionLabel' => 'Open Baseline Compare',
'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
];
}
if ($operationsFollowUpCount > 0) {
$items[] = [
'key' => 'operations_follow_up',
'title' => 'Operations need follow-up',
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
'badge' => 'Operations',
'badgeColor' => 'danger',
'actionLabel' => 'Open operations',
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
];
}
$healthyChecks = [];
if ($items === []) {
$healthyChecks = [
[
'title' => 'Baseline compare looks trustworthy',
'body' => $aggregate->headline,
],
[
'title' => 'No overdue findings',
'body' => 'No open findings are currently overdue for this tenant.',
],
[
'title' => 'Accepted-risk governance is healthy',
'body' => 'No accepted-risk findings currently need governance follow-up.',
],
[
'title' => 'No high severity active findings',
'body' => 'No high severity findings are currently open for this tenant.',
],
$activeRuns > 0
? [
'title' => 'Operations are active',
'body' => "{$activeRuns} run(s) are active, but nothing currently needs follow-up.",
]
: [
'title' => 'No active operations',
'body' => 'Nothing is currently running for this tenant.',
],
];
}
return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'items' => $items,
'healthyChecks' => $healthyChecks,
];
}
/**
* @param array<string, mixed> $parameters
* @return array<string, mixed>
*/
private function findingsAction(Tenant $tenant, string $label, array $parameters): array
{
$url = $this->canOpenFindings($tenant)
? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant)
: null;
return [
'actionLabel' => $label,
'actionUrl' => $url,
'actionDisabled' => $url === null,
'helperText' => $url === null ? UiTooltips::INSUFFICIENT_PERMISSION : null,
];
}
private function canOpenFindings(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
}
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
{
/** @var TenantGovernanceAggregateResolver $resolver */
$resolver = app(TenantGovernanceAggregateResolver::class);
/** @var TenantGovernanceAggregate $aggregate */
$aggregate = $resolver->forTenant($tenant);
return $aggregate;
}
}

View File

@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
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;
use App\Support\OpsUx\OperationUxPresenter;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
class RecentOperations extends TableWidget
{
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
$tenant = Filament::getTenant();
return $table
->heading('Recent Operations')
->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
->columns([
TextColumn::make('short_id')
->label(OperationRunLinks::identifierLabel())
->state(fn (OperationRun $record): string => OperationRunLinks::identifier($record))
->copyable()
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
TextColumn::make('type')
->label('Operation')
->sortable()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->limit(40)
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status')
->badge()
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->sortable()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('created_at')
->label('Started')
->sortable()
->since(),
])
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
? OperationRunLinks::view($record, $tenant)
: null)
->emptyStateHeading('No operations yet')
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
}
/**
* @return Builder<OperationRun>
*/
private function getQuery(): Builder
{
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
return OperationRun::query()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->latest('created_at');
}
}

View File

@ -1,168 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Inventory;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class InventoryKpiHeader extends StatsOverviewWidget
{
use ResolvesPanelTenantContext;
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
/**
* Inventory KPI aggregation source-of-truth:
* - `inventory_items.policy_type`
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
* - dependency capability via `CoverageCapabilitiesResolver`
*
* @return array<Stat>
*/
protected function getStats(): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return [
Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
Stat::make('Active ops', 0),
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
];
}
$tenantId = (int) $tenant->getKey();
/** @var array<string, int> $countsByPolicyType */
$countsByPolicyType = InventoryItem::query()
->where('tenant_id', $tenantId)
->selectRaw('policy_type, COUNT(*) as aggregate')
->groupBy('policy_type')
->pluck('aggregate', 'policy_type')
->map(fn ($value): int => (int) $value)
->all();
$totalItems = array_sum($countsByPolicyType);
$restorableItems = 0;
$partialItems = 0;
$riskItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
$restorableItems += $count;
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
$partialItems += $count;
}
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
$riskItems += $count;
}
}
$coveragePercent = $totalItems > 0
? (int) round(($restorableItems / $totalItems) * 100)
: 0;
$lastRun = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
->latest('id')
->first();
$lastInventorySyncTimeLabel = '—';
$lastInventorySyncStatusLabel = '—';
$lastInventorySyncStatusColor = 'gray';
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
$lastInventorySyncViewUrl = null;
if ($lastRun instanceof OperationRun) {
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
if ($timestamp) {
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
}
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
$lastInventorySyncStatusLabel = $badge->label;
$lastInventorySyncStatusColor = $badge->color;
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
}
$badgeColor = $lastInventorySyncStatusColor;
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
Open operation
</x-filament::link>
@endif
</div>
BLADE, [
'badgeColor' => $badgeColor,
'statusLabel' => $lastInventorySyncStatusLabel,
'viewUrl' => $lastInventorySyncViewUrl,
]);
$activeOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
$inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->active()
->count();
$resolver = app(CoverageCapabilitiesResolver::class);
$dependenciesItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
$dependenciesItems += $count;
}
}
return [
Stat::make('Total items', $totalItems),
Stat::make('Coverage', $coveragePercent.'%')
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
->description(new HtmlString($lastInventorySyncDescription)),
Stat::make('Active ops', $activeOps),
Stat::make('Inventory ops', $inventoryOps)
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
];
}
}

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use Illuminate\Support\Facades\Blade;
class InventoryKpiBadges
{
public static function coverage(int $restorableCount, int $partialCount): string
{
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
Restorable {{ $restorableCount }}
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
Partial {{ $partialCount }}
</x-filament::badge>
</div>
BLADE, [
'restorableCount' => $restorableCount,
'partialCount' => $partialCount,
]);
}
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
{
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="gray" size="sm">
Dependencies {{ $dependenciesCount }}
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
Risk {{ $riskCount }}
</x-filament::badge>
</div>
BLADE, [
'dependenciesCount' => $dependenciesCount,
'riskCount' => $riskCount,
]);
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Models\Tenant;
final class ActiveRuns
{
public static function existForTenant(Tenant $tenant): bool
{
return OperationRun::query()
->where('tenant_id', $tenant->getKey())
->active()
->exists();
}
}

View File

@ -3,6 +3,8 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
SAIL_FILES=../../docker-compose.yml
TENANTATLAS_REPO_ROOT=../..
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@ -21,11 +23,12 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=127.0.0.1 DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
FORWARD_DB_PORT=55432
DB_DATABASE=tenantatlas DB_DATABASE=tenantatlas
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD= DB_PASSWORD=postgres
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -43,7 +46,7 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
class SeedBackupHealthBrowserFixture extends Command
{
protected $signature = 'tenantpilot:backup-health:seed-browser-fixture {--force-refresh : Rebuild the fixture backup basis even if it already exists}';
protected $description = 'Seed a local/testing browser fixture for the Spec 180 blocked backup drill-through scenario.';
public function handle(): int
{
if (! app()->environment(['local', 'testing'])) {
$this->error('This fixture command is limited to local and testing environments.');
return self::FAILURE;
}
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
if (! is_array($fixture)) {
$this->error('The backup-health browser smoke fixture is not configured.');
return self::FAILURE;
}
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
$tenantRouteKey = (string) ($scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
$workspace = Workspace::query()->updateOrCreate(
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 180 Backup Health Smoke')],
);
$password = (string) ($userConfig['password'] ?? 'password');
$user = User::query()->updateOrCreate(
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+180@tenantpilot.local')],
[
'name' => (string) ($userConfig['name'] ?? 'Spec 180 Requester'),
'password' => Hash::make($password),
'email_verified_at' => now(),
],
);
$tenant = Tenant::query()->updateOrCreate(
['external_id' => $tenantRouteKey],
[
'workspace_id' => (int) $workspace->getKey(),
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey,
'app_client_id' => (string) ($scenarioConfig['app_client_id'] ?? '18000000-0000-4000-8000-000000000182'),
'app_client_secret' => null,
'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null,
'status' => Tenant::STATUS_ACTIVE,
'environment' => 'dev',
'is_current' => false,
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
],
);
WorkspaceMembership::query()->updateOrCreate(
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
['role' => 'owner'],
);
TenantMembership::query()->updateOrCreate(
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
);
if (Schema::hasColumn('users', 'last_workspace_id')) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
if (Schema::hasTable('user_tenant_preferences')) {
UserTenantPreference::query()->updateOrCreate(
['user_id' => (int) $user->getKey(), 'tenant_id' => (int) $tenant->getKey()],
['last_used_at' => now()],
);
}
$policy = Policy::query()->updateOrCreate(
[
'tenant_id' => (int) $tenant->getKey(),
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
],
[
'display_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
'platform' => 'windows',
'last_synced_at' => now(),
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
],
);
$backupSet = BackupSet::withTrashed()->firstOrNew([
'tenant_id' => (int) $tenant->getKey(),
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
]);
$backupSet->forceFill([
'created_by' => (string) $user->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
'deleted_at' => null,
])->save();
if (method_exists($backupSet, 'trashed') && $backupSet->trashed()) {
$backupSet->restore();
}
$backupItem = BackupItem::withTrashed()->firstOrNew([
'backup_set_id' => (int) $backupSet->getKey(),
'policy_identifier' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
]);
$backupItem->forceFill([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'platform' => 'windows',
'captured_at' => $backupSet->completed_at,
'payload' => [
'id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
'name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
],
'metadata' => [
'policy_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
'fixture' => 'spec-180-browser-smoke',
],
'assignments' => [],
'deleted_at' => null,
])->save();
if (method_exists($backupItem, 'trashed') && $backupItem->trashed()) {
$backupItem->restore();
}
if ((bool) $this->option('force-refresh')) {
$backupSet->forceFill([
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
])->save();
$backupItem->forceFill([
'captured_at' => $backupSet->completed_at,
])->save();
}
$this->table(
['Fixture', 'Value'],
[
['Workspace', (string) $workspace->name],
['User email', (string) $user->email],
['User password', $password],
['Tenant', (string) $tenant->name],
['Tenant external id', (string) $tenant->external_id],
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
['Locally denied capability', 'tenant.view'],
],
);
$this->info('The dashboard remains visible for this fixture user, while backup drill-through routes stay forbidden via a local/testing-only capability deny seam.');
return self::SUCCESS;
}
}

View File

@ -6,19 +6,20 @@
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog; use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -56,6 +57,8 @@ class InventoryCoverage extends Page implements HasTable
protected string $view = 'filament.pages.inventory-coverage'; protected string $view = 'filament.pages.inventory-coverage';
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -67,7 +70,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.') ->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.'); ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
} }
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
@ -110,9 +113,12 @@ protected function getHeaderWidgets(): array
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable() ->searchable()
->searchPlaceholder('Search by policy type or label') ->searchPlaceholder('Search by type or label')
->defaultSort('label') ->defaultSort('follow_up_priority')
->defaultPaginationPageOption(50) ->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage()) ->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function ( ->records(function (
@ -142,14 +148,16 @@ public function table(Table $table): Table
); );
}) })
->columns([ ->columns([
TextColumn::make('type') TextColumn::make('coverage_state')
->label('Type') ->label('Coverage state')
->sortable() ->badge()
->fontFamily(FontFamily::Mono) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
->copyable() ->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
->wrap(), ->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
->sortable(),
TextColumn::make('label') TextColumn::make('label')
->label('Label') ->label('Type')
->sortable() ->sortable()
->badge() ->badge()
->formatStateUsing(function (?string $state, array $record): string { ->formatStateUsing(function (?string $state, array $record): string {
@ -179,17 +187,29 @@ public function table(Table $table): Table
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}) })
->wrap(), ->wrap(),
TextColumn::make('risk') TextColumn::make('follow_up_guidance')
->label('Risk') ->label('Follow-up guidance')
->wrap()
->toggleable(),
TextColumn::make('observed_item_count')
->label('Observed items')
->numeric()
->sortable(),
TextColumn::make('category')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk)) ->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk)) ->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk)) ->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)), ->iconColor(function (?string $state): ?string {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
return $spec->iconColor ?? $spec->color;
})
->toggleable()
->wrap(),
TextColumn::make('restore') TextColumn::make('restore')
->label('Restore') ->label('Restore')
->badge() ->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
return filled($state) return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label ? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
@ -213,20 +233,7 @@ public function table(Table $table): Table
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state); $spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}), })
TextColumn::make('category')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
->toggleable()
->wrap(),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(), ->toggleable(),
IconColumn::make('dependencies') IconColumn::make('dependencies')
->label('Dependencies') ->label('Dependencies')
@ -237,10 +244,31 @@ public function table(Table $table): Table
->falseColor('gray') ->falseColor('gray')
->alignCenter() ->alignCenter()
->toggleable(), ->toggleable(),
TextColumn::make('type')
->label('Type key')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('risk')
->label('Risk')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk))
->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters($this->tableFilters()) ->filters($this->tableFilters())
->emptyStateHeading('No coverage entries match this view') ->emptyStateHeading('No coverage rows match this report')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.') ->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
->emptyStateIcon('heroicon-o-funnel') ->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters') Action::make('clear_filters')
@ -261,6 +289,14 @@ public function table(Table $table): Table
protected function tableFilters(): array protected function tableFilters(): array
{ {
$filters = [ $filters = [
SelectFilter::make('coverage_state')
->label('Coverage state')
->options([
'succeeded' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
'failed' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
'skipped' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
'unknown' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
]),
SelectFilter::make('category') SelectFilter::make('category')
->label('Category') ->label('Category')
->options($this->categoryFilterOptions()), ->options($this->categoryFilterOptions()),
@ -279,84 +315,36 @@ protected function tableFilters(): array
* @return Collection<string, array{ * @return Collection<string, array{
* __key: string, * __key: string,
* key: string, * key: string,
* segment: string,
* type: string, * type: string,
* segment: string,
* label: string, * label: string,
* category: string, * category: string,
* dependencies: bool, * platform: ?string,
* coverage_state: string,
* follow_up_required: bool,
* follow_up_priority: int,
* follow_up_guidance: string,
* observed_item_count: int,
* basis_item_count: ?int,
* basis_error_code: ?string,
* restore: ?string, * restore: ?string,
* risk: string, * risk: ?string,
* source_order: int * dependencies: bool,
* is_basis_payload_backed: bool
* }> * }>
*/ */
protected function coverageRows(): Collection protected function coverageRows(): Collection
{ {
$resolver = app(CoverageCapabilitiesResolver::class); $truth = $this->coverageTruth();
$supported = $this->mapCoverageRows( if (! $truth instanceof TenantCoverageTruth) {
rows: InventoryPolicyTypeMeta::supported(), return collect();
segment: 'policy', }
sourceOrderOffset: 0,
resolver: $resolver,
);
return $supported->merge($this->mapCoverageRows( return collect($truth->rows)
rows: InventoryPolicyTypeMeta::foundations(), ->mapWithKeys(static fn ($row): array => [
segment: 'foundation', $row->key => $row->toArray(),
sourceOrderOffset: $supported->count(), ]);
resolver: $resolver,
));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function mapCoverageRows(
array $rows,
string $segment,
int $sourceOrderOffset,
CoverageCapabilitiesResolver $resolver
): Collection {
return collect($rows)
->values()
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
$type = (string) ($row['type'] ?? '');
if ($type === '') {
return [];
}
$key = "{$segment}:{$type}";
$restore = $row['restore'] ?? null;
$risk = $row['risk'] ?? 'n/a';
return [
$key => [
'__key' => $key,
'key' => $key,
'segment' => $segment,
'type' => $type,
'label' => (string) ($row['label'] ?? $type),
'category' => (string) ($row['category'] ?? 'Other'),
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
'restore' => is_string($restore) ? $restore : null,
'risk' => is_string($risk) ? $risk : 'n/a',
'source_order' => $sourceOrderOffset + $index,
],
];
});
} }
/** /**
@ -367,6 +355,7 @@ protected function mapCoverageRows(
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{ {
$normalizedSearch = Str::lower(trim((string) $search)); $normalizedSearch = Str::lower(trim((string) $search));
$coverageState = $filters['coverage_state']['value'] ?? null;
$category = $filters['category']['value'] ?? null; $category = $filters['category']['value'] ?? null;
$restore = $filters['restore']['value'] ?? null; $restore = $filters['restore']['value'] ?? null;
@ -380,6 +369,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
}); });
}, },
) )
->when(
filled($coverageState),
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
)
->when( ->when(
filled($category), filled($category),
fn (Collection $rows): Collection => $rows->where('category', (string) $category), fn (Collection $rows): Collection => $rows->where('category', (string) $category),
@ -396,22 +389,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
*/ */
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{ {
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null; $sortColumn = in_array($sortColumn, ['type', 'label', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
? $sortColumn
: null;
if ($sortColumn === null) { if ($sortColumn === null) {
return $rows->sortBy('source_order'); return $rows;
} }
$records = $rows->all(); $records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int { uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp( $comparison = match ($sortColumn) {
(string) ($left[$sortColumn] ?? ''), 'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
(string) ($right[$sortColumn] ?? ''), 'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
); default => strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
),
};
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
}
if ($comparison === 0) { if ($comparison === 0) {
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0)); $comparison = strnatcasecmp(
(string) ($left['label'] ?? ''),
(string) ($right['label'] ?? ''),
);
} }
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison; return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
@ -468,4 +474,99 @@ protected function restoreFilterOptions(): array
}) })
->all(); ->all();
} }
/**
* @return array<string, mixed>
*/
public function coverageSummary(): array
{
$truth = $this->coverageTruth();
if (! $truth instanceof TenantCoverageTruth) {
return [];
}
return [
'supportedTypes' => $truth->supportedTypeCount,
'succeededTypes' => $truth->succeededTypeCount,
'followUpTypes' => $truth->followUpTypeCount,
'observedItems' => $truth->observedItemTotal,
'observedTypes' => $truth->observedTypeCount(),
'topFollowUpLabel' => $truth->topPriorityFollowUpRow()?->label,
'topFollowUpGuidance' => $truth->topPriorityFollowUpRow()?->followUpGuidance,
'hasCurrentCoverageResult' => $truth->hasCurrentCoverageResult,
];
}
/**
* @return array<string, mixed>
*/
public function basisRunSummary(): array
{
$truth = $this->coverageTruth();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
return [];
}
if (! $truth->basisRun instanceof OperationRun) {
return [
'title' => 'No current coverage basis',
'body' => $user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
? 'Run Inventory Sync from Inventory Items to establish current tenant coverage truth.'
: 'A tenant operator with inventory sync permission must establish current tenant coverage truth.',
'badgeLabel' => null,
'badgeColor' => null,
'runUrl' => null,
'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
return [
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
'body' => $canViewRun
? 'Review the cited inventory sync to inspect provider or permission issues in detail.'
: 'The coverage basis is current, but your role cannot open the cited run detail.',
'badgeLabel' => $badge->label,
'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
protected function coverageTruth(): ?TenantCoverageTruth
{
if ($this->cachedCoverageTruth instanceof TenantCoverageTruth) {
return $this->cachedCoverageTruth;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
$this->cachedCoverageTruth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
return $this->cachedCoverageTruth;
}
private function inventorySyncHistoryUrl(Tenant $tenant): string
{
return route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
}
} }

View File

@ -38,6 +38,7 @@
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -49,6 +50,8 @@ class FindingExceptionsQueue extends Page implements HasTable
public ?int $selectedFindingExceptionId = null; public ?int $selectedFindingExceptionId = null;
public bool $showSelectedExceptionSummary = false;
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
@ -116,11 +119,12 @@ public static function canAccess(): bool
public function mount(): void public function mount(): void
{ {
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null; $this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter(); $this->applyRequestedTenantPrefilter();
if ($this->selectedFindingExceptionId !== null) { if ($this->selectedFindingExceptionId !== null) {
$this->selectedFindingException(); $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
} }
} }
@ -141,6 +145,7 @@ protected function getHeaderActions(): array
$this->removeTableFilter('status'); $this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state'); $this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null; $this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
$this->resetTable(); $this->resetTable();
}); });
@ -165,6 +170,7 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->selectedFindingExceptionId !== null) ->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->action(function (): void { ->action(function (): void {
$this->selectedFindingExceptionId = null; $this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
}); });
$actions[] = Action::make('open_selected_exception') $actions[] = Action::make('open_selected_exception')
@ -325,8 +331,31 @@ public function table(Table $table): Table
->label('Inspect exception') ->label('Inspect exception')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->action(function (FindingException $record): void { ->before(function (FindingException $record): void {
$this->selectedFindingExceptionId = (int) $record->getKey(); $this->selectedFindingExceptionId = (int) $record->getKey();
})
->slideOver()
->stickyModalHeader()
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
->modalHeading(function (): string {
$record = $this->inspectedFindingException();
return $record instanceof FindingException
? 'Finding exception #'.$record->getKey()
: 'Finding exception';
})
->modalDescription(fn (): ?string => $this->inspectedFindingException()?->requested_at?->toDayDateTimeString())
->modalContent(function (): View {
$record = $this->inspectedFindingException();
if (! $record instanceof FindingException) {
return view('filament.pages.monitoring.partials.finding-exception-queue-unavailable');
}
return view('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
'selectedException' => $record,
]);
}), }),
]) ])
->bulkActions([]) ->bulkActions([])
@ -343,6 +372,7 @@ public function table(Table $table): Table
$this->removeTableFilter('status'); $this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state'); $this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null; $this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
$this->resetTable(); $this->resetTable();
}), }),
]); ]);
@ -354,15 +384,7 @@ public function selectedFindingException(): ?FindingException
return null; return null;
} }
$record = $this->queueBaseQuery() return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
->whereKey($this->selectedFindingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
} }
public function selectedExceptionUrl(): ?string public function selectedExceptionUrl(): ?string
@ -508,6 +530,30 @@ private function hasActiveQueueFilters(): bool
|| is_string(data_get($this->tableFilters, 'current_validity_state.value')); || is_string(data_get($this->tableFilters, 'current_validity_state.value'));
} }
private function resolveSelectedFindingException(int $findingExceptionId): FindingException
{
$record = $this->queueBaseQuery()
->whereKey($findingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
}
private function inspectedFindingException(): ?FindingException
{
$mountedRecord = $this->getMountedTableActionRecord();
if ($mountedRecord instanceof FindingException) {
return $mountedRecord;
}
return $this->selectedFindingException();
}
private function governanceWarning(FindingException $record): ?string private function governanceWarning(FindingException $record): ?string
{ {
$finding = $record->relationLoaded('finding') $finding = $record->relationLoaded('finding')

View File

@ -233,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
'active' => $query->healthyActive(), 'active' => $query->healthyActive(),
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
'blocked' => $query->dashboardNeedsFollowUp(), 'blocked' => $query->dashboardNeedsFollowUp(),
'succeeded' => $query 'succeeded' => $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
@ -281,9 +283,29 @@ private function applyRequestedDashboardPrefilter(): void
} }
} }
$requestedProblemClass = request()->query('problemClass');
if (in_array($requestedProblemClass, [
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
], true)) {
$this->activeTab = (string) $requestedProblemClass;
return;
}
$requestedTab = request()->query('activeTab'); $requestedTab = request()->query('activeTab');
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) { if (in_array($requestedTab, [
'all',
'active',
'blocked',
'succeeded',
'partial',
'failed',
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
], true)) {
$this->activeTab = (string) $requestedTab; $this->activeTab = (string) $requestedTab;
} }
} }

View File

@ -22,6 +22,7 @@
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
@ -244,6 +245,42 @@ public function lifecycleBanner(): ?array
}; };
} }
/**
* @return array{tone: string, title: string, body: string, url: ?string, link_label: ?string}|null
*/
public function restoreContinuationBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$continuation = OperationRunResource::restoreContinuation($this->run);
if (! is_array($continuation)) {
return null;
}
$tone = ($continuation['follow_up_required'] ?? false) ? 'amber' : 'sky';
$body = $continuation['summary'] ?? 'Restore continuation detail is unavailable.';
$boundary = $continuation['recovery_claim_boundary'] ?? null;
if (is_string($boundary) && $boundary !== '') {
$body .= ' '.RestoreSafetyCopy::recoveryBoundary($boundary);
}
if (! ($continuation['link_available'] ?? false)) {
$body .= ' Restore detail is not available from this session.';
}
return [
'tone' => $tone,
'title' => 'Restore continuation',
'body' => $body,
'url' => is_string($continuation['link_url'] ?? null) ? $continuation['link_url'] : null,
'link_label' => ($continuation['link_available'] ?? false) ? 'Open restore run' : null,
];
}
/** /**
* @return array{tone: string, title: string, body: string}|null * @return array{tone: string, title: string, body: string}|null
*/ */

View File

@ -395,6 +395,7 @@ public static function table(Table $table): Table
return $nextRun->format('M j, Y H:i:s'); return $nextRun->format('M j, Y H:i:s');
} }
}) })
->description(fn (BackupSchedule $record): ?string => static::scheduleFollowUpDescription($record))
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([
@ -1149,4 +1150,31 @@ protected static function dayOfWeekOptions(): array
7 => 'Sunday', 7 => 'Sunday',
]; ];
} }
protected static function scheduleFollowUpDescription(BackupSchedule $record): ?string
{
if (! $record->is_enabled || $record->trashed()) {
return null;
}
$graceCutoff = now('UTC')->subMinutes(max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30)));
$lastRunStatus = strtolower(trim((string) $record->last_run_status));
$isOverdue = $record->next_run_at?->lessThan($graceCutoff) ?? false;
$neverSuccessful = $record->last_run_at === null
&& ($isOverdue || ($record->created_at?->lessThan($graceCutoff) ?? false));
if ($neverSuccessful) {
return 'No successful run has been recorded yet.';
}
if ($isOverdue) {
return 'This schedule looks overdue.';
}
if (in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true)) {
return 'The last run needs follow-up.';
}
return null;
}
} }

View File

@ -3,7 +3,11 @@
namespace App\Filament\Resources\BackupScheduleResource\Pages; namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use App\Models\Tenant;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
@ -64,4 +68,23 @@ private function syncCanonicalAdminTenantFilterState(): void
tenantFilterName: null, tenantFilterName: null,
); );
} }
public function getSubheading(): ?string
{
if (request()->string('backup_health_reason')->toString() !== TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP) {
return null;
}
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return 'One or more enabled schedules need follow-up.';
}
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
$summary = $resolver->assess($tenant)->scheduleFollowUp->summaryMessage;
return $summary ?? 'One or more enabled schedules need follow-up.';
}
} }

View File

@ -18,6 +18,9 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
@ -161,6 +164,15 @@ public static function table(Table $table): Table
->persistFiltersInSession() ->persistFiltersInSession()
->persistSearchInSession() ->persistSearchInSession()
->persistSortInSession() ->persistSortInSession()
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
'items' => fn ($itemQuery) => $itemQuery->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]))
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') Tables\Columns\TextColumn::make('name')
->searchable() ->searchable()
@ -172,6 +184,11 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(), Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (BackupSet $record): string => static::backupQualitySummary($record)->compactSummary)
->description(fn (BackupSet $record): string => static::backupQualitySummary($record)->nextAction)
->wrap(),
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
@ -659,6 +676,23 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
$metadataKeyCount = count($metadata); $metadataKeyCount = count($metadata);
$relatedContext = static::relatedContextEntries($record); $relatedContext = static::relatedContextEntries($record);
$isArchived = $record->trashed(); $isArchived = $record->trashed();
$qualitySummary = static::backupQualitySummary($record);
$backupHealthAssessment = static::backupHealthContinuityAssessment($record);
$qualityBadge = match (true) {
$qualitySummary->totalItems === 0 => $factory->statusBadge('No items', 'gray'),
$qualitySummary->hasDegradations() => $factory->statusBadge('Degraded input', 'warning', 'heroicon-m-exclamation-triangle'),
default => $factory->statusBadge('No degradations', 'success', 'heroicon-m-check-circle'),
};
$backupHealthBadge = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->statusBadge(
static::backupHealthContinuityLabel($backupHealthAssessment),
$backupHealthAssessment->tone(),
'heroicon-m-exclamation-triangle',
)
: null;
$descriptionHint = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? trim($backupHealthAssessment->headline.' '.($backupHealthAssessment->supportingMessage ?? ''))
: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.';
return EnterpriseDetailBuilder::make('backup_set', 'tenant') return EnterpriseDetailBuilder::make('backup_set', 'tenant')
->header(new SummaryHeaderData( ->header(new SummaryHeaderData(
@ -667,14 +701,46 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
statusBadges: [ statusBadges: [
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'), $factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
...array_filter([$backupHealthBadge]),
$qualityBadge,
], ],
keyFacts: [ keyFacts: [
$factory->keyFact('Items', $record->item_count), $factory->keyFact('Items', $record->item_count),
...array_filter([
$backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
: null,
]),
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
$factory->keyFact('Created by', $record->created_by), $factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)), $factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)), $factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
], ],
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.', descriptionHint: $descriptionHint,
))
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
: null,
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
$factory->keyFact('Integrity warnings', $qualitySummary->integrityWarningCount),
$qualitySummary->unknownQualityCount > 0
? $factory->keyFact('Unknown quality', $qualitySummary->unknownQualityCount)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$qualitySummary->nextAction,
'Backup quality',
),
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
title: 'Backup quality',
)) ))
->addSection( ->addSection(
$factory->factsSection( $factory->factsSection(
@ -700,11 +766,12 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
->addSupportingCard( ->addSupportingCard(
$factory->supportingFactsCard( $factory->supportingFactsCard(
kind: 'status', kind: 'status',
title: 'Recovery readiness', title: 'Backup quality counts',
items: [ items: [
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
$factory->keyFact('Archived', $isArchived), $factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
$factory->keyFact('Metadata keys', $metadataKeyCount), $factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
], ],
), ),
$factory->supportingFactsCard( $factory->supportingFactsCard(
@ -740,4 +807,64 @@ private static function formatDetailTimestamp(mixed $value): string
return $value->toDayDateTimeString(); return $value->toDayDateTimeString();
} }
private static function backupQualitySummary(BackupSet $record): \App\Support\BackupQuality\BackupQualitySummary
{
if ($record->trashed()) {
$record->setRelation('items', $record->items()->withTrashed()->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
])->get());
} elseif (! $record->relationLoaded('items')) {
$record->loadMissing([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]);
}
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
}
private static function backupHealthContinuityAssessment(BackupSet $record): ?TenantBackupHealthAssessment
{
$requestedReason = request()->string('backup_health_reason')->toString();
if (! in_array($requestedReason, [
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], true)) {
return null;
}
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
$assessment = $resolver->assess((int) $record->tenant_id);
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
return null;
}
if ($assessment->primaryReason !== $requestedReason) {
return null;
}
return $assessment;
}
private static function backupHealthContinuityLabel(TenantBackupHealthAssessment $assessment): string
{
return match ($assessment->primaryReason) {
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
default => ucfirst($assessment->posture),
};
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\BackupSetResource\Pages; namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -40,4 +41,14 @@ protected function getTableEmptyStateActions(): array
BackupSetResource::makeCreateAction(), BackupSetResource::makeCreateAction(),
]; ];
} }
public function getSubheading(): ?string
{
return match (request()->string('backup_health_reason')->toString()) {
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => 'No usable completed backup basis is currently available for this tenant.',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'The latest backup detail is no longer available, so this view stays on the backup-set list.',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'The latest backup detail is no longer available, so this view stays on the backup-set list.',
default => null,
};
}
} }

View File

@ -11,6 +11,7 @@
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -279,11 +280,32 @@ public function table(Table $table): Table
->sortable() ->sortable()
->searchable() ->searchable()
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()), ->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
Tables\Columns\TextColumn::make('snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Tables\Columns\TextColumn::make('policyVersion.version_number') Tables\Columns\TextColumn::make('policyVersion.version_number')
->label('Version') ->label('Version')
->badge() ->badge()
->default('—') ->default('—')
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number), ->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->compactSummary)
->description(function (BackupItem $record): string {
$summary = $this->backupItemQualitySummary($record);
if ($summary->assignmentCaptureReason === 'separate_role_assignments') {
return 'Assignments are captured separately for this item type.';
}
return $summary->nextAction;
})
->wrap(),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->label('Type') ->label('Type')
->badge() ->badge()
@ -480,6 +502,11 @@ private function backupItemInspectUrl(BackupItem $record): ?string
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant); return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
} }
private function backupItemQualitySummary(BackupItem $record): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forBackupItem($record);
}
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
{ {
$recordId = $this->normalizeBackupItemKey($record); $recordId = $this->normalizeBackupItemKey($record);

Some files were not shown because too many files have changed in this diff Show More