Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
7f2c82c26d feat: harden evidence freshness publication trust 2026-04-04 13:29:40 +02:00
2462 changed files with 3295 additions and 28774 deletions

View File

@ -1,9 +1,7 @@
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
@ -20,19 +18,12 @@ 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

@ -3,8 +3,6 @@ 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
@ -23,12 +21,11 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=127.0.0.1
DB_PORT=5432 DB_PORT=5432
FORWARD_DB_PORT=55432
DB_DATABASE=tenantatlas DB_DATABASE=tenantatlas
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=postgres DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -46,7 +43,7 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=redis REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

View File

@ -2,12 +2,6 @@ # 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)
@ -133,22 +127,6 @@ ## Active Technologies
- PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment) - PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment)
- 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 (174-evidence-freshness-publication-trust) - 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 (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) - 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)
- 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)
@ -168,8 +146,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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 - 172-deferred-operator-surfaces-retrofit: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
<!-- 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 `cd apps/platform && php artisan filament:assets`. - Deployment must include `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
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `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 `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. - 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.
## 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,29 +372,28 @@ ## 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.
- 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. - 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 `cd apps/platform && ./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 `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 `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. - 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.
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `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
@ -405,7 +404,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 `cd apps/platform && ./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 `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.
@ -429,10 +428,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 `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. - 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.
### Vite Error ### Vite Error
- 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`. - 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`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -461,7 +460,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 `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components. - Use the `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.
@ -505,8 +504,8 @@ ## Testing Livewire
## Laravel Pint Code Formatter ## Laravel Pint Code Formatter
- 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. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- 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. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
@ -515,7 +514,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 `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`. - All tests must be written using Pest. Use `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.
@ -528,9 +527,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: `cd apps/platform && ./vendor/bin/sail artisan test --compact`. - To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`. - To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- 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). - To filter on a particular test name: `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,30 +15,19 @@
/.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
@ -46,5 +35,3 @@ Thumbs.db
/tests/Browser/Screenshots /tests/Browser/Screenshots
*.tmp *.tmp
*.swp *.swp
/apps/platform/.env
/apps/platform/.env.*

View File

@ -1,11 +1,8 @@
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,19 +2,12 @@ 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,20 +1,32 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.0.0 -> 2.1.0 - Version change: 1.14.0 -> 2.0.0
- Modified principles: - Modified principles:
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST - Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract
with cross-reference to new HDR-001 - Filament UI - Layout & Information Architecture Standards (UX-001) -> Operator-Facing UI/UX Constitution v1 / Filament UI - Layout & Information Architecture Standards (UX-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:
- Header Action Discipline & Contextual Navigation (HDR-001) - Operator-Facing UI/UX Constitution v1 (UI-CONST-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/plan-template.md (Constitution Check: HDR-001 added) - ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added) - ✅ .specify/templates/plan-template.md
- ⚠ .specify/templates/spec-template.md (no changes needed; existing - ✅ .specify/templates/tasks-template.md
UI/UX Surface Classification and Operator Surface Contract tables already - ✅ docs/product/principles.md
cover header action placement implicitly) - ✅ docs/product/standards/README.md
- ✅ 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:
@ -523,7 +535,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 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). - Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups.
- 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.
@ -536,121 +548,6 @@ #### 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.
@ -775,7 +672,6 @@ #### 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
@ -794,9 +690,6 @@ #### 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
@ -811,9 +704,6 @@ #### 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`.
@ -897,4 +787,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.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07 **Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28

View File

@ -70,8 +70,7 @@ ## 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 (see HDR-001); 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; 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,7 +72,6 @@ # 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,13 +318,12 @@ ## Security
## Commands ## Commands
### Sail (preferred locally) ### Sail (preferred locally)
- `cd apps/platform && ./vendor/bin/sail up -d` - `./vendor/bin/sail up -d`
- `cd apps/platform && ./vendor/bin/sail down` - `./vendor/bin/sail down`
- `cd apps/platform && ./vendor/bin/sail composer install` - `./vendor/bin/sail composer install`
- `cd apps/platform && ./vendor/bin/sail artisan migrate` - `./vendor/bin/sail artisan migrate`
- `cd apps/platform && ./vendor/bin/sail artisan test` - `./vendor/bin/sail artisan test`
- `cd apps/platform && ./vendor/bin/sail artisan` (general) - `./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.
@ -336,10 +335,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)
- `cd apps/platform && composer install` - `composer install`
- `cd apps/platform && php artisan serve` - `php artisan serve`
- `cd apps/platform && php artisan migrate` - `php artisan migrate`
- `cd apps/platform && php artisan test` - `php artisan test`
### Frontend/assets/tooling (if present) ### Frontend/assets/tooling (if present)
- `pnpm install` - `pnpm install`
@ -353,11 +352,11 @@ ## Where to look first
- `.specify/` - `.specify/`
- `AGENTS.md` - `AGENTS.md`
- `README.md` - `README.md`
- `apps/platform/app/` - `app/`
- `apps/platform/database/` - `database/`
- `apps/platform/routes/` - `routes/`
- `apps/platform/resources/` - `resources/`
- `apps/platform/config/` - `config/`
--- ---
@ -434,7 +433,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 `cd apps/platform && php artisan filament:assets`. - Deployment must include `php artisan filament:assets`.
Sources: Sources:
- https://filamentphp.com/docs/5.x/panel-configuration - https://filamentphp.com/docs/5.x/panel-configuration
@ -671,7 +670,7 @@ ## Testing
## Deployment / Ops ## Deployment / Ops
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `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 ===
@ -721,7 +720,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 `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. - 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.
## Documentation Files ## Documentation Files
@ -813,28 +812,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 `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments. - View all available Sail commands by running `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 `cd apps/platform && ./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 `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 `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. - 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.
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `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
@ -847,7 +846,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 `cd apps/platform && ./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 `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
@ -878,11 +877,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 `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. - 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.
## Vite Error ## Vite Error
- 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`. - 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`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -913,15 +912,15 @@ ### Models
# Laravel Pint Code Formatter # Laravel Pint Code Formatter
- 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. - You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- 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. - 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.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`. - This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`. - Run tests: `vendor/bin/sail artisan test --compact` or filter: `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,13 +156,12 @@ ## Security
## Commands ## Commands
### Sail (preferred locally) ### Sail (preferred locally)
- `cd apps/platform && ./vendor/bin/sail up -d` - `./vendor/bin/sail up -d`
- `cd apps/platform && ./vendor/bin/sail down` - `./vendor/bin/sail down`
- `cd apps/platform && ./vendor/bin/sail composer install` - `./vendor/bin/sail composer install`
- `cd apps/platform && ./vendor/bin/sail artisan migrate` - `./vendor/bin/sail artisan migrate`
- `cd apps/platform && ./vendor/bin/sail artisan test` - `./vendor/bin/sail artisan test`
- `cd apps/platform && ./vendor/bin/sail artisan` (general) - `./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.
@ -174,10 +173,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)
- `cd apps/platform && composer install` - `composer install`
- `cd apps/platform && php artisan serve` - `php artisan serve`
- `cd apps/platform && php artisan migrate` - `php artisan migrate`
- `cd apps/platform && php artisan test` - `php artisan test`
### Frontend/assets/tooling (if present) ### Frontend/assets/tooling (if present)
- `pnpm install` - `pnpm install`
@ -191,11 +190,11 @@ ## Where to look first
- `.specify/` - `.specify/`
- `AGENTS.md` - `AGENTS.md`
- `README.md` - `README.md`
- `apps/platform/app/` - `app/`
- `apps/platform/database/` - `database/`
- `apps/platform/routes/` - `routes/`
- `apps/platform/resources/` - `resources/`
- `apps/platform/config/` - `config/`
--- ---
@ -272,7 +271,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 `cd apps/platform && php artisan filament:assets`. - Deployment must include `php artisan filament:assets`.
Sources: Sources:
- https://filamentphp.com/docs/5.x/panel-configuration - https://filamentphp.com/docs/5.x/panel-configuration
@ -509,7 +508,7 @@ ## Testing
## Deployment / Ops ## Deployment / Ops
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `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 ===
@ -559,7 +558,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 `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. - 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.
## Documentation Files ## Documentation Files
@ -651,28 +650,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 `cd apps/platform && ./vendor/bin/sail up -d` and stop them with `cd apps/platform && ./vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `cd apps/platform && ./vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments. - View all available Sail commands by running `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 `cd apps/platform && ./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 `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 `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. - 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.
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`. - If you're creating a generic PHP class, use `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
@ -685,7 +684,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 `cd apps/platform && ./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 `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources ### APIs & Eloquent Resources
@ -716,11 +715,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 `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. - 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.
## Vite Error ## Vite Error
- 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`. - 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`.
=== laravel/v12 rules === === laravel/v12 rules ===
@ -751,15 +750,15 @@ ### Models
# Laravel Pint Code Formatter # Laravel Pint Code Formatter
- 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. - You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- 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. - 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.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
- This project uses Pest for testing. Create tests: `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`. - This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact` or filter: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName`. - Run tests: `vendor/bin/sail artisan test --compact` or filter: `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,18 +9,11 @@
## TenantPilot setup ## TenantPilot setup
- Platform app root: `apps/platform` - Local dev (Sail-first):
- Repo-root ownership: specs, docs, scripts, editor config, agent config, orchestration, and `docker-compose.yml` - Start stack: `./vendor/bin/sail up -d`
- App-root ownership: Laravel runtime, tests, Vite assets, public entrypoints, `composer.json`, `package.json`, `drizzle.config.ts`, and app-local `.env*` - Init DB: `./vendor/bin/sail artisan migrate --seed`
- Local dev (Sail-first, canonical workflow): - Tests: `./vendor/bin/sail artisan test`
- Install: `cd apps/platform && composer install` - Policy sync: `./vendor/bin/sail artisan intune:sync-policies`
- 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`
@ -32,17 +25,10 @@ ## 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).
@ -53,23 +39,8 @@ ### 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: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`. - Check worker status/logs: `./vendor/bin/sail ps` and `./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
@ -93,7 +64,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: `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). - 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).
- 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

@ -6,20 +6,19 @@
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\Inventory\TenantCoverageTruth; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Inventory\TenantCoverageTruthResolver; use App\Support\Inventory\InventoryPolicyTypeMeta;
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;
@ -57,8 +56,6 @@ 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)
@ -70,7 +67,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 tenant coverage report.'); ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
} }
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
@ -113,12 +110,9 @@ 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 type or label') ->searchPlaceholder('Search by policy type or label')
->defaultSort('follow_up_priority') ->defaultSort('label')
->defaultPaginationPageOption(50) ->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage()) ->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function ( ->records(function (
@ -148,17 +142,15 @@ public function table(Table $table): Table
); );
}) })
->columns([ ->columns([
TextColumn::make('coverage_state') TextColumn::make('type')
->label('Coverage state')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
->sortable(),
TextColumn::make('label')
->label('Type') ->label('Type')
->sortable() ->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap(),
TextColumn::make('label')
->label('Label')
->sortable()
->badge() ->badge()
->formatStateUsing(function (?string $state, array $record): string { ->formatStateUsing(function (?string $state, array $record): string {
return TagBadgeCatalog::spec( return TagBadgeCatalog::spec(
@ -187,29 +179,17 @@ public function table(Table $table): Table
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}) })
->wrap(), ->wrap(),
TextColumn::make('follow_up_guidance') TextColumn::make('risk')
->label('Follow-up guidance') ->label('Risk')
->wrap()
->toggleable(),
TextColumn::make('observed_item_count')
->label('Observed items')
->numeric()
->sortable(),
TextColumn::make('category')
->badge() ->badge()
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color) ->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon) ->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(function (?string $state): ?string { ->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
$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
@ -233,7 +213,20 @@ 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')
@ -244,31 +237,10 @@ 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 rows match this report') ->emptyStateHeading('No coverage entries match this view')
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.') ->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
->emptyStateIcon('heroicon-o-funnel') ->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters') Action::make('clear_filters')
@ -289,14 +261,6 @@ 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()),
@ -315,36 +279,84 @@ protected function tableFilters(): array
* @return Collection<string, array{ * @return Collection<string, array{
* __key: string, * __key: string,
* key: string, * key: string,
* type: string,
* segment: string, * segment: string,
* type: string,
* label: string, * label: string,
* category: string, * category: string,
* 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,
* risk: ?string,
* dependencies: bool, * dependencies: bool,
* is_basis_payload_backed: bool * restore: ?string,
* risk: string,
* source_order: int
* }> * }>
*/ */
protected function coverageRows(): Collection protected function coverageRows(): Collection
{ {
$truth = $this->coverageTruth(); $resolver = app(CoverageCapabilitiesResolver::class);
if (! $truth instanceof TenantCoverageTruth) { $supported = $this->mapCoverageRows(
return collect(); rows: InventoryPolicyTypeMeta::supported(),
} segment: 'policy',
sourceOrderOffset: 0,
resolver: $resolver,
);
return collect($truth->rows) return $supported->merge($this->mapCoverageRows(
->mapWithKeys(static fn ($row): array => [ rows: InventoryPolicyTypeMeta::foundations(),
$row->key => $row->toArray(), segment: 'foundation',
]); 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,
],
];
});
} }
/** /**
@ -355,7 +367,6 @@ protected function coverageRows(): Collection
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;
@ -369,10 +380,6 @@ 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),
@ -389,35 +396,22 @@ 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', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true) $sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
? $sortColumn
: null;
if ($sortColumn === null) { if ($sortColumn === null) {
return $rows; return $rows->sortBy('source_order');
} }
$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 = match ($sortColumn) { $comparison = strnatcasecmp(
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)), (string) ($left[$sortColumn] ?? ''),
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)), (string) ($right[$sortColumn] ?? ''),
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 = strnatcasecmp( $comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
(string) ($left['label'] ?? ''),
(string) ($right['label'] ?? ''),
);
} }
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison; return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
@ -474,99 +468,4 @@ 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

@ -10,7 +10,6 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
@ -80,23 +79,9 @@ protected function getHeaderWidgets(): array
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$actions = app(OperateHubShell::class)->headerActions( return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts', scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts', returnActionName: 'operate_hub_return_alerts',
); );
$navigationContext = CanonicalNavigationContext::fromRequest(request());
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
array_splice($actions, 1, 0, [
Action::make('operate_hub_back_to_origin_alerts')
->label($navigationContext->backLinkLabel)
->icon('heroicon-o-arrow-left')
->color('gray')
->url($navigationContext->backLinkUrl),
]);
}
return $actions;
} }
} }

View File

@ -38,7 +38,6 @@
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;
@ -50,8 +49,6 @@ 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';
@ -119,12 +116,11 @@ 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->resolveSelectedFindingException($this->selectedFindingExceptionId); $this->selectedFindingException();
} }
} }
@ -145,7 +141,6 @@ 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();
}); });
@ -170,7 +165,6 @@ 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')
@ -331,31 +325,8 @@ public function table(Table $table): Table
->label('Inspect exception') ->label('Inspect exception')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->before(function (FindingException $record): void { ->action(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([])
@ -372,7 +343,6 @@ 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();
}), }),
]); ]);
@ -384,7 +354,15 @@ public function selectedFindingException(): ?FindingException
return null; return null;
} }
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId); $record = $this->queueBaseQuery()
->whereKey($this->selectedFindingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
} }
public function selectedExceptionUrl(): ?string public function selectedExceptionUrl(): ?string
@ -530,30 +508,6 @@ 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

@ -74,8 +74,6 @@ public function mount(): void
{ {
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$this->applyRequestedTenantScope();
app(CanonicalAdminTenantFilterState::class)->sync( app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(), $this->getTableFiltersSessionKey(),
['type', 'initiator_name'], ['type', 'initiator_name'],
@ -189,17 +187,6 @@ public function table(Table $table): Table
}); });
} }
private function applyRequestedTenantScope(): void
{
if (! $this->shouldForceWorkspaceWideTenantScope()) {
return;
}
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
}
/** /**
* @return array{likely_stale:int,reconciled:int} * @return array{likely_stale:int,reconciled:int}
*/ */
@ -233,8 +220,6 @@ 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)
@ -273,45 +258,18 @@ private function scopedSummaryQuery(): ?Builder
private function applyRequestedDashboardPrefilter(): void private function applyRequestedDashboardPrefilter(): void
{ {
if (! $this->shouldForceWorkspaceWideTenantScope()) { $requestedTenantId = request()->query('tenant_id');
$requestedTenantId = request()->query('tenant_id');
if (is_numeric($requestedTenantId)) { if (is_numeric($requestedTenantId)) {
$tenantId = (string) $requestedTenantId; $tenantId = (string) $requestedTenantId;
$this->tableFilters['tenant_id']['value'] = $tenantId; $this->tableFilters['tenant_id']['value'] = $tenantId;
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId; $this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
}
}
$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, [ if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) {
'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;
} }
} }
private function shouldForceWorkspaceWideTenantScope(): bool
{
return request()->query('tenant_scope') === 'all';
}
} }

View File

@ -22,7 +22,6 @@
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;
@ -245,42 +244,6 @@ 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

@ -87,6 +87,7 @@
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -3333,7 +3334,7 @@ private function canInspectOperationRun(OperationRun $run): bool
return false; return false;
} }
return $user->can('view', $run); return Gate::forUser($user)->allows('view', $run);
} }
public function verificationSucceeded(): bool public function verificationSucceeded(): bool

View File

@ -395,7 +395,6 @@ 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([
@ -1150,31 +1149,4 @@ 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,11 +3,7 @@
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;
@ -68,23 +64,4 @@ 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,9 +18,6 @@
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;
@ -164,15 +161,6 @@ 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()
@ -184,11 +172,6 @@ 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),
@ -676,23 +659,6 @@ 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(
@ -701,46 +667,14 @@ 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: $descriptionHint, descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
))
->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(
@ -766,12 +700,11 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
->addSupportingCard( ->addSupportingCard(
$factory->supportingFactsCard( $factory->supportingFactsCard(
kind: 'status', kind: 'status',
title: 'Backup quality counts', title: 'Recovery readiness',
items: [ items: [
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount), $factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount), $factory->keyFact('Archived', $isArchived),
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount), $factory->keyFact('Metadata keys', $metadataKeyCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
], ],
), ),
$factory->supportingFactsCard( $factory->supportingFactsCard(
@ -807,64 +740,4 @@ 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,7 +3,6 @@
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;
@ -41,14 +40,4 @@ 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,7 +11,6 @@
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;
@ -280,32 +279,11 @@ 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()
@ -502,11 +480,6 @@ 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