Compare commits
6 Commits
feat/177-i
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ce0615a9c1 | |||
| 6f8eb28ca2 | |||
| e840007127 | |||
| a107e7e41b | |||
| 1142d283eb | |||
| f52d52540c |
@ -1,7 +1,9 @@
|
||||
node_modules/
|
||||
apps/platform/node_modules/
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
coverage/
|
||||
.git/
|
||||
.DS_Store
|
||||
@ -18,12 +20,19 @@ Dockerfile*
|
||||
*.tmp
|
||||
*.swp
|
||||
public/build/
|
||||
apps/platform/public/build/
|
||||
public/hot/
|
||||
apps/platform/public/hot/
|
||||
public/storage/
|
||||
apps/platform/public/storage/
|
||||
storage/framework/
|
||||
apps/platform/storage/framework/
|
||||
storage/logs/
|
||||
apps/platform/storage/logs/
|
||||
storage/debugbar/
|
||||
apps/platform/storage/debugbar/
|
||||
storage/*.key
|
||||
apps/platform/storage/*.key
|
||||
/references/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
24
.github/agents/copilot-instructions.md
vendored
24
.github/agents/copilot-instructions.md
vendored
@ -2,6 +2,12 @@ # TenantAtlas Development Guidelines
|
||||
|
||||
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
|
||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||
@ -131,6 +137,18 @@ ## Active Technologies
|
||||
- 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)
|
||||
|
||||
@ -150,8 +168,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 179-provider-truth-cleanup: Added 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
|
||||
- 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
49
.github/copilot-instructions.md
vendored
49
.github/copilot-instructions.md
vendored
@ -40,7 +40,7 @@ ## 3) Panel setup defaults
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- 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”
|
||||
|
||||
## Deployment / Ops
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
|
||||
=== foundation rules ===
|
||||
@ -292,7 +292,7 @@ ## Application Structure & Architecture
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
@ -372,28 +372,29 @@ ## Enums
|
||||
## Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
- 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 `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 `cd apps/platform && ./vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## 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.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
@ -404,7 +405,7 @@ ### Database
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
@ -428,10 +429,10 @@ ### Configuration
|
||||
### Testing
|
||||
- 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()`.
|
||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
@ -460,7 +461,7 @@ ### Models
|
||||
## Livewire
|
||||
|
||||
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||
- Use the `cd apps/platform && ./vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||
|
||||
@ -504,8 +505,8 @@ ## Testing Livewire
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- 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 `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
||||
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test`, simply run `cd apps/platform && ./vendor/bin/sail bin pint` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
@ -514,7 +515,7 @@ ### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- All tests must be written using Pest. Use `cd apps/platform && ./vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
@ -527,9 +528,9 @@ ### Pest Tests
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `vendor/bin/sail artisan test --compact`.
|
||||
- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
- To run all tests: `cd apps/platform && ./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 filter on a particular test name: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@ -15,19 +15,30 @@
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/apps/platform/node_modules
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/apps/platform/public/build
|
||||
/public/hot
|
||||
/apps/platform/public/hot
|
||||
/public/storage
|
||||
/apps/platform/public/storage
|
||||
/storage/*.key
|
||||
/apps/platform/storage/*.key
|
||||
/storage/pail
|
||||
/apps/platform/storage/pail
|
||||
/storage/framework
|
||||
/apps/platform/storage/framework
|
||||
/storage/logs
|
||||
/apps/platform/storage/logs
|
||||
/storage/debugbar
|
||||
/apps/platform/storage/debugbar
|
||||
/vendor
|
||||
/apps/platform/vendor
|
||||
/bootstrap/cache
|
||||
/apps/platform/bootstrap/cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@ -35,3 +46,5 @@ Thumbs.db
|
||||
/tests/Browser/Screenshots
|
||||
*.tmp
|
||||
*.swp
|
||||
/apps/platform/.env
|
||||
/apps/platform/.env.*
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
dist/
|
||||
build/
|
||||
public/build/
|
||||
apps/platform/public/build/
|
||||
node_modules/
|
||||
apps/platform/node_modules/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -2,12 +2,19 @@ node_modules/
|
||||
dist/
|
||||
build/
|
||||
public/build/
|
||||
apps/platform/public/build/
|
||||
public/hot/
|
||||
apps/platform/public/hot/
|
||||
public/storage/
|
||||
apps/platform/public/storage/
|
||||
coverage/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
apps/platform/node_modules/
|
||||
storage/
|
||||
apps/platform/storage/
|
||||
bootstrap/cache/
|
||||
apps/platform/bootstrap/cache/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
@ -1,32 +1,20 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.14.0 -> 2.0.0
|
||||
- Version change: 2.0.0 -> 2.1.0
|
||||
- Modified principles:
|
||||
- Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract
|
||||
- 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)
|
||||
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
||||
with cross-reference to new HDR-001
|
||||
- Added sections:
|
||||
- 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
|
||||
- Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
||||
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
||||
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
|
||||
UI/UX Surface Classification and Operator Surface Contract tables already
|
||||
cover header action placement implicitly)
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs:
|
||||
@ -535,7 +523,7 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||
|
||||
Actions and flows
|
||||
- Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups.
|
||||
- Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
|
||||
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||
- Destructive actions remain non-primary and confirmed.
|
||||
|
||||
@ -548,6 +536,121 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||
- 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)
|
||||
|
||||
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
|
||||
@ -672,6 +775,7 @@ #### Appendix A - One-page Condensed Constitution
|
||||
- Standard lists stay scanable.
|
||||
- Exceptions are catalogued, justified, and tested.
|
||||
- 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
|
||||
|
||||
@ -690,6 +794,9 @@ #### Appendix B - Feature Review Checklist
|
||||
- Critical truth is visible.
|
||||
- Scanability is preserved.
|
||||
- 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
|
||||
|
||||
@ -704,6 +811,9 @@ #### Appendix C - Red Flags for Future PRs
|
||||
- Queue surfaces throw the operator out of context through row click.
|
||||
- Critical health or operability truth is hidden by default.
|
||||
- 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
|
||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||
@ -787,4 +897,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28
|
||||
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
||||
|
||||
@ -70,7 +70,8 @@ ## Constitution Check
|
||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||
- Operator surfaces (OPSURF-001): 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 UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
@ -72,6 +72,7 @@ # Tasks: [FEATURE NAME]
|
||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||
- 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,
|
||||
- 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),
|
||||
|
||||
73
Agents.md
73
Agents.md
@ -318,12 +318,13 @@ ## Security
|
||||
## Commands
|
||||
|
||||
### Sail (preferred locally)
|
||||
- `./vendor/bin/sail up -d`
|
||||
- `./vendor/bin/sail down`
|
||||
- `./vendor/bin/sail composer install`
|
||||
- `./vendor/bin/sail artisan migrate`
|
||||
- `./vendor/bin/sail artisan test`
|
||||
- `./vendor/bin/sail artisan` (general)
|
||||
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- `cd apps/platform && ./vendor/bin/sail down`
|
||||
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||
|
||||
### Drizzle (local DB tooling, if configured)
|
||||
- Use only for local/dev workflows.
|
||||
@ -335,10 +336,10 @@ ### Drizzle (local DB tooling, if configured)
|
||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||
|
||||
### Non-Docker fallback (only if needed)
|
||||
- `composer install`
|
||||
- `php artisan serve`
|
||||
- `php artisan migrate`
|
||||
- `php artisan test`
|
||||
- `cd apps/platform && composer install`
|
||||
- `cd apps/platform && php artisan serve`
|
||||
- `cd apps/platform && php artisan migrate`
|
||||
- `cd apps/platform && php artisan test`
|
||||
|
||||
### Frontend/assets/tooling (if present)
|
||||
- `pnpm install`
|
||||
@ -352,11 +353,11 @@ ## Where to look first
|
||||
- `.specify/`
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `app/`
|
||||
- `database/`
|
||||
- `routes/`
|
||||
- `resources/`
|
||||
- `config/`
|
||||
- `apps/platform/app/`
|
||||
- `apps/platform/database/`
|
||||
- `apps/platform/routes/`
|
||||
- `apps/platform/resources/`
|
||||
- `apps/platform/config/`
|
||||
|
||||
---
|
||||
|
||||
@ -433,7 +434,7 @@ ## 3) Panel setup defaults
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
@ -670,7 +671,7 @@ ## Testing
|
||||
|
||||
## Deployment / Ops
|
||||
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
|
||||
=== foundation rules ===
|
||||
@ -720,7 +721,7 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -812,28 +813,28 @@ ## PHPDoc Blocks
|
||||
# Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# 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.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
@ -846,7 +847,7 @@ ## Database
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
@ -877,11 +878,11 @@ ## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
@ -912,15 +913,15 @@ ### Models
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- 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.
|
||||
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
|
||||
- This project uses Pest for testing. Create tests: `cd apps/platform && ./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`.
|
||||
- Do NOT delete tests without approval.
|
||||
- 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.
|
||||
|
||||
73
GEMINI.md
73
GEMINI.md
@ -156,12 +156,13 @@ ## Security
|
||||
## Commands
|
||||
|
||||
### Sail (preferred locally)
|
||||
- `./vendor/bin/sail up -d`
|
||||
- `./vendor/bin/sail down`
|
||||
- `./vendor/bin/sail composer install`
|
||||
- `./vendor/bin/sail artisan migrate`
|
||||
- `./vendor/bin/sail artisan test`
|
||||
- `./vendor/bin/sail artisan` (general)
|
||||
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- `cd apps/platform && ./vendor/bin/sail down`
|
||||
- `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan` (general)
|
||||
- Root helper for tooling only: `./scripts/platform-sail ...`
|
||||
|
||||
### Drizzle (local DB tooling, if configured)
|
||||
- Use only for local/dev workflows.
|
||||
@ -173,10 +174,10 @@ ### Drizzle (local DB tooling, if configured)
|
||||
(Agents should confirm the exact script names in `package.json` before suggesting them.)
|
||||
|
||||
### Non-Docker fallback (only if needed)
|
||||
- `composer install`
|
||||
- `php artisan serve`
|
||||
- `php artisan migrate`
|
||||
- `php artisan test`
|
||||
- `cd apps/platform && composer install`
|
||||
- `cd apps/platform && php artisan serve`
|
||||
- `cd apps/platform && php artisan migrate`
|
||||
- `cd apps/platform && php artisan test`
|
||||
|
||||
### Frontend/assets/tooling (if present)
|
||||
- `pnpm install`
|
||||
@ -190,11 +191,11 @@ ## Where to look first
|
||||
- `.specify/`
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `app/`
|
||||
- `database/`
|
||||
- `routes/`
|
||||
- `resources/`
|
||||
- `config/`
|
||||
- `apps/platform/app/`
|
||||
- `apps/platform/database/`
|
||||
- `apps/platform/routes/`
|
||||
- `apps/platform/resources/`
|
||||
- `apps/platform/config/`
|
||||
|
||||
---
|
||||
|
||||
@ -271,7 +272,7 @@ ## 3) Panel setup defaults
|
||||
- Assets policy:
|
||||
- Panel-only assets: register via panel config.
|
||||
- Shared/plugin assets: register via `FilamentAsset::register()`.
|
||||
- Deployment must include `php artisan filament:assets`.
|
||||
- Deployment must include `cd apps/platform && php artisan filament:assets`.
|
||||
|
||||
Sources:
|
||||
- https://filamentphp.com/docs/5.x/panel-configuration
|
||||
@ -508,7 +509,7 @@ ## Testing
|
||||
|
||||
## Deployment / Ops
|
||||
|
||||
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- [ ] `cd apps/platform && php artisan filament:assets` is included in the deployment process when using registered assets.
|
||||
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
|
||||
|
||||
=== foundation rules ===
|
||||
@ -558,7 +559,7 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -650,28 +651,28 @@ ## PHPDoc Blocks
|
||||
# Laravel Sail
|
||||
|
||||
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
|
||||
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
|
||||
- Open the application in the browser by running `vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `vendor/bin/sail composer install`
|
||||
- Execute Node commands: `vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `vendor/bin/sail` without arguments.
|
||||
- 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 `cd apps/platform && ./vendor/bin/sail open`.
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# 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.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `cd apps/platform && ./vendor/bin/sail artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
|
||||
- Use `cd apps/platform && ./vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `cd apps/platform && ./vendor/bin/sail artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
@ -684,7 +685,7 @@ ## Database
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `cd apps/platform && ./vendor/bin/sail artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
@ -715,11 +716,11 @@ ## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
@ -750,15 +751,15 @@ ### Models
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- 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.
|
||||
- You must run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `cd apps/platform && ./vendor/bin/sail bin pint --test --format agent`, simply run `cd apps/platform && ./vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
|
||||
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
|
||||
- This project uses Pest for testing. Create tests: `cd apps/platform && ./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`.
|
||||
- Do NOT delete tests without approval.
|
||||
- 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.
|
||||
|
||||
43
README.md
43
README.md
@ -9,11 +9,18 @@
|
||||
|
||||
## TenantPilot setup
|
||||
|
||||
- Local dev (Sail-first):
|
||||
- Start stack: `./vendor/bin/sail up -d`
|
||||
- Init DB: `./vendor/bin/sail artisan migrate --seed`
|
||||
- Tests: `./vendor/bin/sail artisan test`
|
||||
- Policy sync: `./vendor/bin/sail artisan intune:sync-policies`
|
||||
- Platform app root: `apps/platform`
|
||||
- Repo-root ownership: specs, docs, scripts, editor config, agent config, orchestration, and `docker-compose.yml`
|
||||
- App-root ownership: Laravel runtime, tests, Vite assets, public entrypoints, `composer.json`, `package.json`, `drizzle.config.ts`, and app-local `.env*`
|
||||
- Local dev (Sail-first, canonical workflow):
|
||||
- Install: `cd apps/platform && composer install`
|
||||
- Env bootstrap: `cd apps/platform && cp .env.example .env`
|
||||
- Start stack: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Generate app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||
- Init DB: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||
- Tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
- Policy sync: `cd apps/platform && ./vendor/bin/sail artisan intune:sync-policies`
|
||||
- Compatibility helper for tooling that cannot set a nested working directory: `./scripts/platform-sail ...`
|
||||
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
||||
- Microsoft Graph (Intune) env vars:
|
||||
- `GRAPH_TENANT_ID`
|
||||
@ -25,10 +32,17 @@ ## TenantPilot setup
|
||||
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
||||
- Deployment (Dokploy, staging → production):
|
||||
- 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.
|
||||
- Ensure queue workers are running for jobs (e.g., policy sync) after deploy.
|
||||
- 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 actions are available in Filament resource tables (Policies, Policy Versions, Backup Sets, Restore Runs).
|
||||
@ -39,8 +53,23 @@ ### Troubleshooting
|
||||
|
||||
- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect).
|
||||
- Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`.
|
||||
- Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`.
|
||||
- Check worker status/logs: `cd apps/platform && ./vendor/bin/sail ps` and `cd apps/platform && ./vendor/bin/sail logs -f queue`.
|
||||
- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container.
|
||||
- **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
|
||||
|
||||
@ -64,7 +93,7 @@ ## Graph Contract Registry & Drift Guard
|
||||
- Sanitizes `$select`/`$expand` to allowed fields; logs warnings on trim.
|
||||
- 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.
|
||||
- Drift check: `php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
||||
- Drift check: `cd apps/platform && php artisan graph:contract:check [--tenant=]` runs lightweight probes against contract endpoints to detect capability/shape issues; useful in staging/CI (prod optional).
|
||||
- If Graph returns capability errors, TenantPilot downgrades safely, records warnings/audit entries, and avoids breaking preview/restore flows.
|
||||
|
||||
## Policy Settings Display
|
||||
|
||||
@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class DashboardKpis extends StatsOverviewWidget
|
||||
{
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return $this->emptyStats();
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$openDriftFindings = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->openDrift()
|
||||
->count();
|
||||
|
||||
$highSeverityActiveFindings = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->highSeverityActive()
|
||||
->count();
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->healthyActive()
|
||||
->count();
|
||||
|
||||
$followUpRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->dashboardNeedsFollowUp()
|
||||
->count();
|
||||
|
||||
$openDriftUrl = $openDriftFindings > 0
|
||||
? $this->findingsUrl($tenant, [
|
||||
'tab' => 'needs_action',
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
])
|
||||
: null;
|
||||
$highSeverityUrl = $highSeverityActiveFindings > 0
|
||||
? $this->findingsUrl($tenant, [
|
||||
'tab' => 'needs_action',
|
||||
'high_severity' => 1,
|
||||
])
|
||||
: null;
|
||||
$findingsHelperText = $this->findingsHelperText($tenant);
|
||||
|
||||
return [
|
||||
Stat::make('Open drift findings', $openDriftFindings)
|
||||
->description($openDriftUrl === null && $openDriftFindings > 0
|
||||
? $findingsHelperText
|
||||
: 'active drift workflow items')
|
||||
->color($openDriftFindings > 0 ? 'warning' : 'gray')
|
||||
->url($openDriftUrl),
|
||||
Stat::make('High severity active findings', $highSeverityActiveFindings)
|
||||
->description($highSeverityUrl === null && $highSeverityActiveFindings > 0
|
||||
? $findingsHelperText
|
||||
: 'high or critical findings needing review')
|
||||
->color($highSeverityActiveFindings > 0 ? 'danger' : 'gray')
|
||||
->url($highSeverityUrl),
|
||||
Stat::make('Active operations', $activeRuns)
|
||||
->description('healthy queued or running tenant work')
|
||||
->color($activeRuns > 0 ? 'info' : 'gray')
|
||||
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
|
||||
Stat::make('Operations needing follow-up', $followUpRuns)
|
||||
->description('failed, warning, or stalled runs')
|
||||
->color($followUpRuns > 0 ? 'danger' : 'gray')
|
||||
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
private function emptyStats(): array
|
||||
{
|
||||
return [
|
||||
Stat::make('Open drift findings', 0),
|
||||
Stat::make('High severity active findings', 0),
|
||||
Stat::make('Active operations', 0),
|
||||
Stat::make('Operations needing follow-up', 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
private function findingsUrl(Tenant $tenant, array $parameters): ?string
|
||||
{
|
||||
if (! $this->canOpenFindings($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
private function findingsHelperText(Tenant $tenant): string
|
||||
{
|
||||
return $this->canOpenFindings($tenant)
|
||||
? 'Open findings'
|
||||
: UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
}
|
||||
|
||||
private function canOpenFindings(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||
}
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class NeedsAttention extends Widget
|
||||
{
|
||||
protected string $view = 'filament.widgets.dashboard.needs-attention';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'items' => [],
|
||||
'healthyChecks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
$compareAssessment = $aggregate->summaryAssessment;
|
||||
|
||||
$items = [];
|
||||
|
||||
$overdueOpenCount = $aggregate->overdueOpenFindingsCount;
|
||||
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||
$operationsFollowUpCount = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->dashboardNeedsFollowUp()
|
||||
->count();
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->healthyActive()
|
||||
->count();
|
||||
|
||||
if ($lapsedGovernanceCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'lapsed_governance',
|
||||
'title' => 'Lapsed accepted-risk governance',
|
||||
'body' => "{$lapsedGovernanceCount} accepted-risk finding(s) no longer have valid supporting governance.",
|
||||
'badge' => 'Governance',
|
||||
'badgeColor' => 'danger',
|
||||
...$this->findingsAction(
|
||||
$tenant,
|
||||
'Open findings',
|
||||
[
|
||||
'tab' => 'risk_accepted',
|
||||
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if ($overdueOpenCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'overdue_findings',
|
||||
'title' => 'Overdue findings',
|
||||
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
|
||||
'badge' => 'Findings',
|
||||
'badgeColor' => 'danger',
|
||||
...$this->findingsAction(
|
||||
$tenant,
|
||||
'Open findings',
|
||||
['tab' => 'overdue'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if ($expiringGovernanceCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'expiring_governance',
|
||||
'title' => 'Expiring accepted-risk governance',
|
||||
'body' => "{$expiringGovernanceCount} accepted-risk finding(s) need governance review soon.",
|
||||
'badge' => 'Governance',
|
||||
'badgeColor' => 'warning',
|
||||
...$this->findingsAction(
|
||||
$tenant,
|
||||
'Open findings',
|
||||
[
|
||||
'tab' => 'risk_accepted',
|
||||
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if ($highSeverityCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'high_severity_active_findings',
|
||||
'title' => 'High severity active findings',
|
||||
'body' => "{$highSeverityCount} high or critical finding(s) are still active.",
|
||||
'badge' => 'Findings',
|
||||
'badgeColor' => 'danger',
|
||||
...$this->findingsAction(
|
||||
$tenant,
|
||||
'Open findings',
|
||||
[
|
||||
'tab' => 'needs_action',
|
||||
'high_severity' => 1,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if ($compareAssessment->stateFamily !== 'positive') {
|
||||
$items[] = [
|
||||
'key' => 'baseline_compare_posture',
|
||||
'title' => 'Baseline compare posture',
|
||||
'body' => $compareAssessment->headline,
|
||||
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||
'badge' => 'Baseline',
|
||||
'badgeColor' => $compareAssessment->tone,
|
||||
'actionLabel' => 'Open Baseline Compare',
|
||||
'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
if ($operationsFollowUpCount > 0) {
|
||||
$items[] = [
|
||||
'key' => 'operations_follow_up',
|
||||
'title' => 'Operations need follow-up',
|
||||
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'danger',
|
||||
'actionLabel' => 'Open operations',
|
||||
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
||||
];
|
||||
}
|
||||
|
||||
$healthyChecks = [];
|
||||
|
||||
if ($items === []) {
|
||||
$healthyChecks = [
|
||||
[
|
||||
'title' => 'Baseline compare looks trustworthy',
|
||||
'body' => $aggregate->headline,
|
||||
],
|
||||
[
|
||||
'title' => 'No overdue findings',
|
||||
'body' => 'No open findings are currently overdue for this tenant.',
|
||||
],
|
||||
[
|
||||
'title' => 'Accepted-risk governance is healthy',
|
||||
'body' => 'No accepted-risk findings currently need governance follow-up.',
|
||||
],
|
||||
[
|
||||
'title' => 'No high severity active findings',
|
||||
'body' => 'No high severity findings are currently open for this tenant.',
|
||||
],
|
||||
$activeRuns > 0
|
||||
? [
|
||||
'title' => 'Operations are active',
|
||||
'body' => "{$activeRuns} run(s) are active, but nothing currently needs follow-up.",
|
||||
]
|
||||
: [
|
||||
'title' => 'No active operations',
|
||||
'body' => 'Nothing is currently running for this tenant.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'items' => $items,
|
||||
'healthyChecks' => $healthyChecks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findingsAction(Tenant $tenant, string $label, array $parameters): array
|
||||
{
|
||||
$url = $this->canOpenFindings($tenant)
|
||||
? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => $url,
|
||||
'actionDisabled' => $url === null,
|
||||
'helperText' => $url === null ? UiTooltips::INSUFFICIENT_PERMISSION : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function canOpenFindings(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
|
||||
/** @var TenantGovernanceAggregate $aggregate */
|
||||
$aggregate = $resolver->forTenant($tenant);
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RecentOperations extends TableWidget
|
||||
{
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $table
|
||||
->heading('Recent Operations')
|
||||
->query($this->getQuery())
|
||||
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
|
||||
->columns([
|
||||
TextColumn::make('short_id')
|
||||
->label(OperationRunLinks::identifierLabel())
|
||||
->state(fn (OperationRun $record): string => OperationRunLinks::identifier($record))
|
||||
->copyable()
|
||||
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->sortable()
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->limit(40)
|
||||
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->sortable()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
->sortable()
|
||||
->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($record, $tenant)
|
||||
: null)
|
||||
->emptyStateHeading('No operations yet')
|
||||
->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<OperationRun>
|
||||
*/
|
||||
private function getQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
|
||||
|
||||
return OperationRun::query()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->latest('created_at');
|
||||
}
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryKpiBadges;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class InventoryKpiHeader extends StatsOverviewWidget
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* Inventory KPI aggregation source-of-truth:
|
||||
* - `inventory_items.policy_type`
|
||||
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
||||
* - dependency capability via `CoverageCapabilitiesResolver`
|
||||
*
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
Stat::make('Total items', 0),
|
||||
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
|
||||
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
|
||||
Stat::make('Active ops', 0),
|
||||
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
/** @var array<string, int> $countsByPolicyType */
|
||||
$countsByPolicyType = InventoryItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
||||
->groupBy('policy_type')
|
||||
->pluck('aggregate', 'policy_type')
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$totalItems = array_sum($countsByPolicyType);
|
||||
|
||||
$restorableItems = 0;
|
||||
$partialItems = 0;
|
||||
$riskItems = 0;
|
||||
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
||||
$restorableItems += $count;
|
||||
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
||||
$partialItems += $count;
|
||||
}
|
||||
|
||||
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
||||
$riskItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
$coveragePercent = $totalItems > 0
|
||||
? (int) round(($restorableItems / $totalItems) * 100)
|
||||
: 0;
|
||||
|
||||
$lastRun = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$lastInventorySyncTimeLabel = '—';
|
||||
$lastInventorySyncStatusLabel = '—';
|
||||
$lastInventorySyncStatusColor = 'gray';
|
||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
||||
$lastInventorySyncViewUrl = null;
|
||||
|
||||
if ($lastRun instanceof OperationRun) {
|
||||
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
|
||||
|
||||
if ($timestamp) {
|
||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
||||
}
|
||||
|
||||
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
|
||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||
$lastInventorySyncStatusLabel = $badge->label;
|
||||
$lastInventorySyncStatusColor = $badge->color;
|
||||
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
|
||||
|
||||
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
|
||||
}
|
||||
|
||||
$badgeColor = $lastInventorySyncStatusColor;
|
||||
|
||||
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge :color="$badgeColor" size="sm">
|
||||
{{ $statusLabel }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($viewUrl)
|
||||
<x-filament::link :href="$viewUrl" size="sm">
|
||||
Open operation
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
BLADE, [
|
||||
'badgeColor' => $badgeColor,
|
||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
||||
'viewUrl' => $lastInventorySyncViewUrl,
|
||||
]);
|
||||
|
||||
$activeOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory_sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$dependenciesItems = 0;
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
||||
$dependenciesItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
Stat::make('Total items', $totalItems),
|
||||
Stat::make('Coverage', $coveragePercent.'%')
|
||||
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
||||
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
||||
->description(new HtmlString($lastInventorySyncDescription)),
|
||||
Stat::make('Active ops', $activeOps),
|
||||
Stat::make('Inventory ops', $inventoryOps)
|
||||
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
class InventoryKpiBadges
|
||||
{
|
||||
public static function coverage(int $restorableCount, int $partialCount): string
|
||||
{
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge color="success" size="sm">
|
||||
Restorable {{ $restorableCount }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
Partial {{ $partialCount }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
BLADE, [
|
||||
'restorableCount' => $restorableCount,
|
||||
'partialCount' => $partialCount,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
|
||||
{
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Dependencies {{ $dependenciesCount }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge color="danger" size="sm">
|
||||
Risk {{ $riskCount }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
BLADE, [
|
||||
'dependenciesCount' => $dependenciesCount,
|
||||
'riskCount' => $riskCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
final class ActiveRuns
|
||||
{
|
||||
public static function existForTenant(Tenant $tenant): bool
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->active()
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
SAIL_FILES=../../docker-compose.yml
|
||||
TENANTATLAS_REPO_ROOT=../..
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@ -21,11 +23,12 @@ LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
FORWARD_DB_PORT=55432
|
||||
DB_DATABASE=tenantatlas
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@ -43,7 +46,7 @@ CACHE_STORE=database
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class SeedBackupHealthBrowserFixture extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:backup-health:seed-browser-fixture {--force-refresh : Rebuild the fixture backup basis even if it already exists}';
|
||||
|
||||
protected $description = 'Seed a local/testing browser fixture for the Spec 180 blocked backup drill-through scenario.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
$this->error('This fixture command is limited to local and testing environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
|
||||
|
||||
if (! is_array($fixture)) {
|
||||
$this->error('The backup-health browser smoke fixture is not configured.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
||||
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
||||
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
|
||||
$tenantRouteKey = (string) ($scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
|
||||
|
||||
$workspace = Workspace::query()->updateOrCreate(
|
||||
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
|
||||
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 180 Backup Health Smoke')],
|
||||
);
|
||||
|
||||
$password = (string) ($userConfig['password'] ?? 'password');
|
||||
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+180@tenantpilot.local')],
|
||||
[
|
||||
'name' => (string) ($userConfig['name'] ?? 'Spec 180 Requester'),
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$tenant = Tenant::query()->updateOrCreate(
|
||||
['external_id' => $tenantRouteKey],
|
||||
[
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
|
||||
'tenant_id' => $tenantRouteKey,
|
||||
'app_client_id' => (string) ($scenarioConfig['app_client_id'] ?? '18000000-0000-4000-8000-000000000182'),
|
||||
'app_client_secret' => null,
|
||||
'app_certificate_thumbprint' => null,
|
||||
'app_status' => 'ok',
|
||||
'app_notes' => null,
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
'environment' => 'dev',
|
||||
'is_current' => false,
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
WorkspaceMembership::query()->updateOrCreate(
|
||||
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner'],
|
||||
);
|
||||
|
||||
TenantMembership::query()->updateOrCreate(
|
||||
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
|
||||
);
|
||||
|
||||
if (Schema::hasColumn('users', 'last_workspace_id')) {
|
||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_tenant_preferences')) {
|
||||
UserTenantPreference::query()->updateOrCreate(
|
||||
['user_id' => (int) $user->getKey(), 'tenant_id' => (int) $tenant->getKey()],
|
||||
['last_used_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
$policy = Policy::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||
],
|
||||
[
|
||||
'display_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
],
|
||||
);
|
||||
|
||||
$backupSet = BackupSet::withTrashed()->firstOrNew([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
|
||||
]);
|
||||
|
||||
$backupSet->forceFill([
|
||||
'created_by' => (string) $user->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
|
||||
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
|
||||
'deleted_at' => null,
|
||||
])->save();
|
||||
|
||||
if (method_exists($backupSet, 'trashed') && $backupSet->trashed()) {
|
||||
$backupSet->restore();
|
||||
}
|
||||
|
||||
$backupItem = BackupItem::withTrashed()->firstOrNew([
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_identifier' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
|
||||
]);
|
||||
|
||||
$backupItem->forceFill([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'platform' => 'windows',
|
||||
'captured_at' => $backupSet->completed_at,
|
||||
'payload' => [
|
||||
'id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
|
||||
'name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||
],
|
||||
'metadata' => [
|
||||
'policy_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
|
||||
'fixture' => 'spec-180-browser-smoke',
|
||||
],
|
||||
'assignments' => [],
|
||||
'deleted_at' => null,
|
||||
])->save();
|
||||
|
||||
if (method_exists($backupItem, 'trashed') && $backupItem->trashed()) {
|
||||
$backupItem->restore();
|
||||
}
|
||||
|
||||
if ((bool) $this->option('force-refresh')) {
|
||||
$backupSet->forceFill([
|
||||
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
|
||||
])->save();
|
||||
|
||||
$backupItem->forceFill([
|
||||
'captured_at' => $backupSet->completed_at,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Fixture', 'Value'],
|
||||
[
|
||||
['Workspace', (string) $workspace->name],
|
||||
['User email', (string) $user->email],
|
||||
['User password', $password],
|
||||
['Tenant', (string) $tenant->name],
|
||||
['Tenant external id', (string) $tenant->external_id],
|
||||
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
|
||||
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
|
||||
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
|
||||
['Locally denied capability', 'tenant.view'],
|
||||
],
|
||||
);
|
||||
|
||||
$this->info('The dashboard remains visible for this fixture user, while backup drill-through routes stay forbidden via a local/testing-only capability deny seam.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -6,19 +6,20 @@
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -56,6 +57,8 @@ class InventoryCoverage extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -67,7 +70,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
@ -110,9 +113,12 @@ protected function getHeaderWidgets(): array
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->searchable()
|
||||
->searchPlaceholder('Search by policy type or label')
|
||||
->defaultSort('label')
|
||||
->searchPlaceholder('Search by type or label')
|
||||
->defaultSort('follow_up_priority')
|
||||
->defaultPaginationPageOption(50)
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
||||
->records(function (
|
||||
@ -142,14 +148,16 @@ public function table(Table $table): Table
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('type')
|
||||
->label('Type')
|
||||
->sortable()
|
||||
->fontFamily(FontFamily::Mono)
|
||||
->copyable()
|
||||
->wrap(),
|
||||
TextColumn::make('coverage_state')
|
||||
->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('Label')
|
||||
->label('Type')
|
||||
->sortable()
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state, array $record): string {
|
||||
@ -179,17 +187,29 @@ public function table(Table $table): Table
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
})
|
||||
->wrap(),
|
||||
TextColumn::make('risk')
|
||||
->label('Risk')
|
||||
TextColumn::make('follow_up_guidance')
|
||||
->label('Follow-up guidance')
|
||||
->wrap()
|
||||
->toggleable(),
|
||||
TextColumn::make('observed_item_count')
|
||||
->label('Observed items')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('category')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
||||
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
|
||||
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
|
||||
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
|
||||
->iconColor(function (?string $state): ?string {
|
||||
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
})
|
||||
->toggleable()
|
||||
->wrap(),
|
||||
TextColumn::make('restore')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
->state(fn (array $record): ?string => $record['restore'])
|
||||
->formatStateUsing(function (?string $state): string {
|
||||
return filled($state)
|
||||
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
|
||||
@ -213,20 +233,7 @@ public function table(Table $table): Table
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
|
||||
|
||||
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(),
|
||||
IconColumn::make('dependencies')
|
||||
->label('Dependencies')
|
||||
@ -237,10 +244,31 @@ public function table(Table $table): Table
|
||||
->falseColor('gray')
|
||||
->alignCenter()
|
||||
->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())
|
||||
->emptyStateHeading('No coverage entries match this view')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
|
||||
->emptyStateHeading('No coverage rows match this report')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
|
||||
->emptyStateIcon('heroicon-o-funnel')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
@ -261,6 +289,14 @@ public function table(Table $table): Table
|
||||
protected function tableFilters(): array
|
||||
{
|
||||
$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')
|
||||
->label('Category')
|
||||
->options($this->categoryFilterOptions()),
|
||||
@ -279,84 +315,36 @@ protected function tableFilters(): array
|
||||
* @return Collection<string, array{
|
||||
* __key: string,
|
||||
* key: string,
|
||||
* segment: string,
|
||||
* type: string,
|
||||
* segment: string,
|
||||
* label: string,
|
||||
* category: string,
|
||||
* dependencies: bool,
|
||||
* platform: ?string,
|
||||
* coverage_state: string,
|
||||
* follow_up_required: bool,
|
||||
* follow_up_priority: int,
|
||||
* follow_up_guidance: string,
|
||||
* observed_item_count: int,
|
||||
* basis_item_count: ?int,
|
||||
* basis_error_code: ?string,
|
||||
* restore: ?string,
|
||||
* risk: string,
|
||||
* source_order: int
|
||||
* risk: ?string,
|
||||
* dependencies: bool,
|
||||
* is_basis_payload_backed: bool
|
||||
* }>
|
||||
*/
|
||||
protected function coverageRows(): Collection
|
||||
{
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
$truth = $this->coverageTruth();
|
||||
|
||||
$supported = $this->mapCoverageRows(
|
||||
rows: InventoryPolicyTypeMeta::supported(),
|
||||
segment: 'policy',
|
||||
sourceOrderOffset: 0,
|
||||
resolver: $resolver,
|
||||
);
|
||||
|
||||
return $supported->merge($this->mapCoverageRows(
|
||||
rows: InventoryPolicyTypeMeta::foundations(),
|
||||
segment: 'foundation',
|
||||
sourceOrderOffset: $supported->count(),
|
||||
resolver: $resolver,
|
||||
));
|
||||
if (! $truth instanceof TenantCoverageTruth) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
],
|
||||
];
|
||||
});
|
||||
return collect($truth->rows)
|
||||
->mapWithKeys(static fn ($row): array => [
|
||||
$row->key => $row->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -367,6 +355,7 @@ protected function mapCoverageRows(
|
||||
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||
{
|
||||
$normalizedSearch = Str::lower(trim((string) $search));
|
||||
$coverageState = $filters['coverage_state']['value'] ?? null;
|
||||
$category = $filters['category']['value'] ?? null;
|
||||
$restore = $filters['restore']['value'] ?? null;
|
||||
|
||||
@ -380,6 +369,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
});
|
||||
},
|
||||
)
|
||||
->when(
|
||||
filled($coverageState),
|
||||
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
|
||||
)
|
||||
->when(
|
||||
filled($category),
|
||||
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
|
||||
@ -396,22 +389,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
*/
|
||||
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
|
||||
$sortColumn = in_array($sortColumn, ['type', 'label', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
|
||||
? $sortColumn
|
||||
: null;
|
||||
|
||||
if ($sortColumn === null) {
|
||||
return $rows->sortBy('source_order');
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$records = $rows->all();
|
||||
|
||||
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
||||
$comparison = strnatcasecmp(
|
||||
$comparison = match ($sortColumn) {
|
||||
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
||||
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
||||
default => strnatcasecmp(
|
||||
(string) ($left[$sortColumn] ?? ''),
|
||||
(string) ($right[$sortColumn] ?? ''),
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
|
||||
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
|
||||
}
|
||||
|
||||
if ($comparison === 0) {
|
||||
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left['label'] ?? ''),
|
||||
(string) ($right['label'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
|
||||
@ -468,4 +474,99 @@ protected function restoreFilterOptions(): array
|
||||
})
|
||||
->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',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,7 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@ -49,6 +50,8 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
|
||||
public ?int $selectedFindingExceptionId = null;
|
||||
|
||||
public bool $showSelectedExceptionSummary = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||||
@ -116,11 +119,12 @@ public static function canAccess(): bool
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
||||
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
|
||||
$this->mountInteractsWithTable();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
|
||||
if ($this->selectedFindingExceptionId !== null) {
|
||||
$this->selectedFindingException();
|
||||
$this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,6 +145,7 @@ protected function getHeaderActions(): array
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
});
|
||||
|
||||
@ -165,6 +170,7 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
});
|
||||
|
||||
$actions[] = Action::make('open_selected_exception')
|
||||
@ -325,8 +331,31 @@ public function table(Table $table): Table
|
||||
->label('Inspect exception')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->action(function (FindingException $record): void {
|
||||
->before(function (FindingException $record): void {
|
||||
$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([])
|
||||
@ -343,6 +372,7 @@ public function table(Table $table): Table
|
||||
$this->removeTableFilter('status');
|
||||
$this->removeTableFilter('current_validity_state');
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
@ -354,15 +384,7 @@ public function selectedFindingException(): ?FindingException
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->queueBaseQuery()
|
||||
->whereKey($this->selectedFindingExceptionId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof FindingException) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
|
||||
}
|
||||
|
||||
public function selectedExceptionUrl(): ?string
|
||||
@ -508,6 +530,30 @@ private function hasActiveQueueFilters(): bool
|
||||
|| 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
|
||||
{
|
||||
$finding = $record->relationLoaded('finding')
|
||||
@ -233,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
'active' => $query->healthyActive(),
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
|
||||
'blocked' => $query->dashboardNeedsFollowUp(),
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
@ -281,9 +283,29 @@ private function applyRequestedDashboardPrefilter(): void
|
||||
}
|
||||
}
|
||||
|
||||
$requestedProblemClass = request()->query('problemClass');
|
||||
|
||||
if (in_array($requestedProblemClass, [
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
$this->activeTab = (string) $requestedProblemClass;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedTab = request()->query('activeTab');
|
||||
|
||||
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) {
|
||||
if (in_array($requestedTab, [
|
||||
'all',
|
||||
'active',
|
||||
'blocked',
|
||||
'succeeded',
|
||||
'partial',
|
||||
'failed',
|
||||
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
|
||||
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
|
||||
], true)) {
|
||||
$this->activeTab = (string) $requestedTab;
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
@ -244,6 +245,42 @@ public function lifecycleBanner(): ?array
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string, url: ?string, link_label: ?string}|null
|
||||
*/
|
||||
public function restoreContinuationBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$continuation = OperationRunResource::restoreContinuation($this->run);
|
||||
|
||||
if (! is_array($continuation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tone = ($continuation['follow_up_required'] ?? false) ? 'amber' : 'sky';
|
||||
$body = $continuation['summary'] ?? 'Restore continuation detail is unavailable.';
|
||||
$boundary = $continuation['recovery_claim_boundary'] ?? null;
|
||||
|
||||
if (is_string($boundary) && $boundary !== '') {
|
||||
$body .= ' '.RestoreSafetyCopy::recoveryBoundary($boundary);
|
||||
}
|
||||
|
||||
if (! ($continuation['link_available'] ?? false)) {
|
||||
$body .= ' Restore detail is not available from this session.';
|
||||
}
|
||||
|
||||
return [
|
||||
'tone' => $tone,
|
||||
'title' => 'Restore continuation',
|
||||
'body' => $body,
|
||||
'url' => is_string($continuation['link_url'] ?? null) ? $continuation['link_url'] : null,
|
||||
'link_label' => ($continuation['link_available'] ?? false) ? 'Open restore run' : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
@ -395,6 +395,7 @@ public static function table(Table $table): Table
|
||||
return $nextRun->format('M j, Y H:i:s');
|
||||
}
|
||||
})
|
||||
->description(fn (BackupSchedule $record): ?string => static::scheduleFollowUpDescription($record))
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
@ -1149,4 +1150,31 @@ protected static function dayOfWeekOptions(): array
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,11 @@
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
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 Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
@ -64,4 +68,23 @@ private function syncCanonicalAdminTenantFilterState(): void
|
||||
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.';
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,9 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
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\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
@ -161,6 +164,15 @@ public static function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
|
||||
'items' => fn ($itemQuery) => $itemQuery->select([
|
||||
'id',
|
||||
'backup_set_id',
|
||||
'payload',
|
||||
'metadata',
|
||||
'assignments',
|
||||
]),
|
||||
]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
@ -172,6 +184,11 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||
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('completed_at')->label('Completed')->dateTime()->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
|
||||
@ -659,6 +676,23 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
$metadataKeyCount = count($metadata);
|
||||
$relatedContext = static::relatedContextEntries($record);
|
||||
$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')
|
||||
->header(new SummaryHeaderData(
|
||||
@ -667,14 +701,46 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||
...array_filter([$backupHealthBadge]),
|
||||
$qualityBadge,
|
||||
],
|
||||
keyFacts: [
|
||||
$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('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
|
||||
descriptionHint: $descriptionHint,
|
||||
))
|
||||
->decisionZone($factory->decisionZone(
|
||||
facts: array_values(array_filter([
|
||||
$backupHealthAssessment instanceof TenantBackupHealthAssessment
|
||||
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
|
||||
: null,
|
||||
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
|
||||
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
|
||||
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
|
||||
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
|
||||
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
|
||||
$factory->keyFact('Integrity warnings', $qualitySummary->integrityWarningCount),
|
||||
$qualitySummary->unknownQualityCount > 0
|
||||
? $factory->keyFact('Unknown quality', $qualitySummary->unknownQualityCount)
|
||||
: null,
|
||||
])),
|
||||
primaryNextStep: $factory->primaryNextStep(
|
||||
$qualitySummary->nextAction,
|
||||
'Backup quality',
|
||||
),
|
||||
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
|
||||
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
|
||||
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
|
||||
title: 'Backup quality',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
@ -700,11 +766,12 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Recovery readiness',
|
||||
title: 'Backup quality counts',
|
||||
items: [
|
||||
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
$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->supportingFactsCard(
|
||||
@ -740,4 +807,64 @@ private static function formatDetailTimestamp(mixed $value): string
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
@ -40,4 +41,14 @@ protected function getTableEmptyStateActions(): array
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
@ -279,11 +280,32 @@ public function table(Table $table): Table
|
||||
->sortable()
|
||||
->searchable()
|
||||
->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')
|
||||
->label('Version')
|
||||
->badge()
|
||||
->default('—')
|
||||
->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')
|
||||
->label('Type')
|
||||
->badge()
|
||||
@ -480,6 +502,11 @@ private function backupItemInspectUrl(BackupItem $record): ?string
|
||||
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
|
||||
{
|
||||
$recordId = $this->normalizeBackupItemKey($record);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user