Compare commits

...

7 Commits

Author SHA1 Message Date
9f6985291e feat: implement spec 192 record page header discipline (#226)
## Summary
- implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage
- add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline`
- improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL
- disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation

## Notes
- Livewire v4 / Filament v5 compliance remains unchanged.
- Provider registration stays in `bootstrap/providers.php`.
- No Global Search behavior was expanded.
- No destructive action confirmation semantics were relaxed.
- The full test suite was not run in this PR.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #226
2026-04-11 21:20:41 +00:00
74210bac2e feat: add baseline compare operator modes (#224)
## Summary
- add adaptive baseline compare presentation modes with `auto`, `dense`, and `compact` route handling on the existing matrix page
- compress support surfaces with staged filters, grouped legends, last-updated and passive refresh cues, compact single-tenant results, and dense multi-tenant scan rendering
- extend the matrix builder plus Pest and browser smoke coverage for visible-set-only compact and dense workflows

## Filament / Laravel notes
- Livewire v4 compliance preserved; no legacy Livewire v3 patterns introduced
- provider registration is unchanged; no `bootstrap/providers.php` changes were needed for this feature
- no globally searchable resources were changed by this branch
- no destructive actions were added; the existing compare action remains simulation-only and non-destructive
- asset strategy is unchanged; no new Filament assets were introduced

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `80` tests passed with `673` assertions
- integrated browser smoke run on `http://localhost/admin/baseline-profiles/20/compare-matrix`

## Scope
- Spec 191 implementation
- spec contract updates in `spec.md`, `tasks.md`, and the logical OpenAPI contract

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #224
2026-04-11 15:48:22 +00:00
f7bbea2623 191-baseline-compare-operator-mode (#223)
## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #223
2026-04-11 12:51:46 +00:00
65e10a2020 Spec 190: tighten baseline compare matrix scanability (#222)
## Summary
- tighten the baseline compare matrix working surface with active filter scope summaries and clearer visible-set disclosure
- improve matrix scanability with a sticky subject column, calmer attention-first cell styling, and Filament form-based filter controls
- replace the misleading perpetual refresh loading state with a passive auto-refresh note and add focused regression coverage

## Testing
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php`

## Notes
- this PR only contains the Spec 190 implementation changes on `190-baseline-compare-matrix`
- follow-up spec drafting for high-density operator mode was intentionally left out of this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #222
2026-04-11 12:32:10 +00:00
eca19819d1 feat: add workspace baseline compare matrix (#221)
## Summary
- add a workspace-scoped baseline compare matrix page under baseline profiles
- derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings
- add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run
- preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics
- include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #221
2026-04-11 10:20:25 +00:00
2f45ff5a84 feat: add portfolio triage review state tracking (#220)
## Summary
- add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking
- surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview
- extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows
- suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged

## Validation
- targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior
- code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- full suite was not re-run in this final step
- branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #220
2026-04-10 21:35:17 +00:00
1655cc481e Spec 188: canonical provider connection state cleanup (#219)
## Summary
- migrate provider connections to the canonical three-dimension state model: lifecycle via `is_enabled`, consent via `consent_status`, and verification via `verification_status`
- remove legacy provider status and health badge paths, update admin and system directory surfaces, and align onboarding, consent callback, verification, resolver, and mutation flows with the new model
- add the Spec 188 artifact set, schema migrations, guard coverage, and expanded provider-state tests across admin, system, onboarding, verification, and rendering paths

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/SystemPanelAuthTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
- integrated browser smoke: validated admin provider list/detail/edit, tenant provider summary, system directory tenant detail, provider-connection search exclusion, and cleaned up the temporary smoke record afterward

## Filament / implementation notes
- Livewire v4.0+ compliance: preserved; this change targets Filament v5 on Livewire v4 and does not introduce older APIs
- Provider registration location: unchanged; Laravel 11+ panel providers remain registered in `bootstrap/providers.php`
- Globally searchable resources: `ProviderConnectionResource` remains intentionally excluded from global search; tenant global search remains enabled and continues to resolve to view pages
- Destructive actions: no new destructive action surface was introduced without confirmation or authorization; existing capability checks continue to gate provider mutations
- Asset strategy: unchanged; no new Filament assets were added, so deploy behavior for `php artisan filament:assets` remains unchanged
- Testing plan covered: system auth, tenant global search, provider lifecycle enable/disable behavior, and provider truth cleanup cutover behavior

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #219
2026-04-10 11:22:56 +00:00
232 changed files with 19466 additions and 1224 deletions

View File

@ -161,6 +161,16 @@ ## Active Technologies
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage) - PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context)
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context) - PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders (192-record-header-discipline)
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -195,8 +205,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages - 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
- 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure - 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
- 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces - 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -109,6 +109,15 @@ ### Mandatory Bloat Check for New Specs (BLOAT-001)
6. Is this current-release truth or future-release preparation? 6. Is this current-release truth or future-release preparation?
- Specs that cannot answer these questions clearly MUST NOT merge. - Specs that cannot answer these questions clearly MUST NOT merge.
### Spec Candidate Gate (SPEC-GATE-001)
- Every new spec candidate MUST pass the Spec Approval Rubric (`.specify/memory/spec-approval-rubric.md`) before progressing beyond Draft status.
- The spec MUST include a filled-out "Spec Candidate Check" section answering the 5 mandatory questions (operator workflow, trust/safety, smallest version, permanent complexity, why now).
- The spec MUST be classified into exactly one approval class: Core Enterprise, Workflow Compression, Cleanup, or Defer.
- The spec MUST include a scored evaluation (6 dimensions, 02 each). Specs scoring below 7/12 MUST NOT be approved without explicit scope reduction.
- If two or more red flags from the rubric are triggered, the spec MUST include an explicit defense justifying why it should proceed.
- Specs classified as "Defer" or scoring 03 MUST NOT be implemented.
- This gate applies to all spec-creating agents (speckit.specify, speckit.plan) and manual spec creation alike.
### Default Bias (BIAS-001) ### Default Bias (BIAS-001)
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively. - Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.

View File

@ -0,0 +1,236 @@
# TenantPilot Spec Approval Rubric (Anti-Overengineering Guardrails)
## Leitsatz
> Kein neuer Layer ohne klaren Operatorgewinn, und kein neuer Spec nur für interne semantische Schönheit.
Ein neuer Spec ist nur dann stark genug, wenn er **sichtbar mehr Produktwahrheit oder Operator-Wirkung** erzeugt als er dauerhafte Systemkomplexität importiert.
Jeder Spec muss zwei Dinge gleichzeitig beweisen:
1. Welches echte Problem wird gelöst?
2. Warum ist diese Lösung die kleinste enterprise-taugliche Form?
Wenn der Spec nur interne Eleganz, feinere Semantik oder mehr Konsistenz bringt, aber keinen klaren Workflow-, Trust- oder Audit-Gewinn, dann ist er **verdächtig**.
---
## 5 Pflichtfragen vor jeder Freigabe
Ein Spec darf nur weiterverfolgt werden, wenn diese 5 Fragen sauber beantwortet sind.
### A. Welcher konkrete Operator-Workflow wird besser?
Nicht abstrakt „Konsistenz verbessern", sondern konkret: welcher Nutzer, auf welcher Fläche, in welchem Schritt, mit welchem heutigen Schmerz, und was danach schneller, sicherer oder ehrlicher wird.
Wenn kein klarer Vorher/Nachher-Workflow benennbar ist → Spec ist zu abstrakt.
### B. Welche falsche oder gefährliche Produktaussage wird verhindert?
Legitime Antworten:
- Falscher „alles okay"-Eindruck
- Irreführende Recovery-Claims
- Unsaubere Ownership
- Fehlende nächste Aktion
- Fehlende Audit-Nachvollziehbarkeit
- Tenant/Workspace Leakage
- RBAC-Missverständnisse
Wenn ein Spec weder Workflow noch Trust verbessert → kaum zu rechtfertigen.
### C. Was ist die kleinste brauchbare Version?
Explizit benennen:
- Was ist die v1-Minimalversion?
- Welche Teile sind bewusst nicht enthalten?
- Welche Generalisierung wird absichtlich verschoben?
Wenn v1 wie ein Framework, eine Plattform oder eine universelle Taxonomie klingt → zu groß.
### D. Welche dauerhafte Komplexität entsteht?
Nicht nur Implementierungsaufwand, sondern Dauerfolgen:
- Neue Models / Tables?
- Neue Enums / Statusachsen?
- Neue UI-Semantik?
- Neue cross-surface Contracts?
- Neue Tests, die dauerhaft gepflegt werden müssen?
- Neue Begriffe, die jeder verstehen muss?
Wenn die Liste lang ist → Produktgewinn muss entsprechend hoch sein.
### E. Warum jetzt?
Legitime Gründe:
- Blockiert Kernworkflow
- Verhindert gefährliche Fehlinterpretation
- Ist Voraussetzung für unmittelbar folgende Hauptdomäne
- Beseitigt echten systemischen Widerspruch
- Wird bereits von mehreren Flächen schmerzhaft benötigt
Schwache Gründe:
- „wäre sauberer"
- „brauchen wir später bestimmt"
- „passt gut zur Architektur"
- „macht das Modell vollständiger"
---
## 4 Spec-Klassen
Jeden Kandidaten zwingend in genau eine Klasse einordnen.
### Klasse 1 — Core Enterprise Spec
Mindestens eins muss stimmen:
- Schützt echte System-/Tenant-/RBAC-Korrektheit
- Verhindert falsche Governance-/Recovery-/Audit-Aussagen
- Schließt klaren Workflow-Gap
- Beseitigt cross-surface Widerspruch mit realem Operator-Schaden
- Ist echte Voraussetzung für eine wichtige Produktfunktion
Dürfen Komplexität einführen, aber nur gezielt.
### Klasse 2 — Workflow Compression Spec
Gut, wenn sie:
- Klickpfade verkürzen
- Kontextverlust senken
- Return-/Drilldown-Kontinuität verbessern
- Triage-/Review-/Run-Bearbeitung beschleunigen
Nützlich, aber klein halten.
### Klasse 3 — Cleanup / Consolidation
- Vereinfachung, Zusammenführung, Entkopplung
- Entfernen von Legacy / Duplikaten
- Reduktion unnötiger Schichten
Explizit erwünscht als Gegengewicht zu Wachstum.
### Klasse 4 — Premature / Defer
Wenn der Kandidat hauptsächlich bringt:
- Neue Semantik, Frameworks, Taxonomien
- Generalisierung für künftige Fälle
- Infrastruktur ohne breite aktuelle Nutzung
→ Nicht freigeben. Verschieben oder brutal einkürzen.
---
## Rote Flaggen
Wenn **zwei oder mehr** zutreffen → Spec muss aktiv verteidigt werden.
| # | Rote Flagge | Prüffrage |
|---|---|---|
| 1 | **Neue Achsen** — neues Truth-Modell, Statusdimension, Taxonomie, Bewertungsachse | Braucht der Operator das wirklich, oder nur das Modell? |
| 2 | **Neue Meta-Infrastruktur** — Presenter, Resolver, Catalog, Matrix, Registry, Builder, Policy-Layer | Sehr hoher Beweiswert nötig. |
| 3 | **Viele Flächen, wenig Nutzerwert** — 6 Flächen „harmonisiert", kein klarer Nutzerflow besser | Architektur um ihrer selbst willen? |
| 4 | **Klingt nach Foundation** — foundation, framework, generalized, reusable, future-proof, canonical semantics | Fast immer erklärungsbedürftig. |
| 5 | **Mehr Begriffe als Outcomes** — lange semantische Erklärung, Nutzerverbesserung kaum in einem Satz | Verdächtig. |
| 6 | **Mehrere Mikrospecs für eine Domäne** — foundation + semantics + presentation + hardening + integration | Zu fein zerlegt. |
---
## Grüne Flaggen
- Löst klar beobachtbaren Operator-Schmerz
- Verbessert echte Entscheidungssituation
- Verhindert konkrete Fehlinterpretation
- Reduziert Navigation oder Denkaufwand
- Vereinfacht bereits existierende Komplexität
- Führt wenig neue Begriffe ein
- Hat klare Nicht-Ziele
- Ist in einer Sitzung gut erklärbar
- Braucht keine neue Meta-Schicht
- Macht mehrere Flächen einfacher statt abstrakter
---
## Bewertungsraster (02 pro Dimension)
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| **Nutzen** | unklar | lokal nützlich | klarer Workflow-/Trust-/Audit-Gewinn |
| **Dringlichkeit** | kann warten | sinnvoll bald | blockiert oder schützt Wichtiges jetzt |
| **Scope-Disziplin** | wirkt wie Framework/Plattform | etwas breit | klar begrenzte v1 |
| **Komplexitätslast** | hohe dauerhafte Last | mittel | niedrig / gut beherrschbar |
| **Produktnähe** | vor allem intern/architektonisch | gemischt | direkt spürbar für Operatoren |
| **Wiederverwendung belegt** | hypothetisch | wahrscheinlich | bereits an mehreren echten Stellen nötig |
### Auswertung
| Score | Entscheidung |
|---|---|
| **1012** | Freigabefähig |
| **79** | Nur freigeben wenn Scope enger gezogen wird |
| **46** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden |
| **03** | Nicht freigeben |
---
## TenantPilot-spezifische Regeln
### Regel A — Keine neue semantische Achse ohne UI-Beweis
Wo wird sie sichtbar? Warum reichen bestehende Achsen nicht? Welche Fehlentscheidung bleibt ohne sie bestehen?
### Regel B — Keine neue Support-/Presentation-Schicht ohne ≥ 3 echte Verbraucher
Registry, Resolver, Catalog, Presenter, Matrix, Explanation-Layer → nur mit mindestens drei echten (nicht künstlich erzeugten) Verbrauchern. Sonst lokal lösen.
### Regel C — Keine Spec-Aufspaltung unterhalb Operator-Domäne
Wenn ein Thema nicht eigenständig als Operator-Problem beschrieben werden kann → kein eigener Spec.
### Regel D — Jeder neue Status braucht eine echte Folgehandlung
Neue Status/Outcome nur erlaubt wenn sie etwas Konkretes ändern: andere nächste Aktion, anderes Routing, andere Audit-Bedeutung, andere Workflow-Behandlung.
### Regel E — Consolidation ist ein legitimer Spec-Typ
Zusammenführen von Semantik, Reduktion von Komplexität, Entfernen von Parallelmodellen, Vereinfachung von Navigation/Resolvern, Rückbau unnötiger Zwischenlayer — aktiv Platz geben.
---
## Freigabe-Template (Pflichtabschnitt in spec.md)
```markdown
## Spec Candidate Check
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
- **Why now**: [Warum jetzt wichtiger als später?]
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
- **Red flags triggered**: [Welche roten Flaggen treffen zu?]
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
- **Decision**: [approve / shrink / merge / defer / reject]
```
---
## Erlaubt vs. Verdächtig (Schnellreferenz)
| Erlaubt | Verdächtig |
|---|---|
| Echte Workflow-Specs | Neue truth sub-axes |
| Governance-/Finding-/Review-Bearbeitbarkeit | Neue explanation frameworks |
| Trust-/Audit-/RBAC-Härtung | Neue presentation taxonomies |
| Portfolio-Operator-Durchsatzverbesserungen | Neue generalized support layers |
| Consolidation-Specs | Mikro-Specs für bereits stark zerlegte Domänen |

View File

@ -5,6 +5,24 @@ # Feature Specification: [FEATURE NAME]
**Status**: Draft **Status**: Draft
**Input**: User description: "$ARGUMENTS" **Input**: User description: "$ARGUMENTS"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
<!-- This section MUST be completed before the spec progresses beyond Draft.
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
- **Why now**: [Warum jetzt wichtiger als später?]
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
- **Red flags triggered**: [Welche roten Flaggen treffen zu? Wenn ≥ 2: explizite Verteidigung nötig]
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
- **Decision**: [approve / shrink / merge / defer / reject]
## Spec Scope Fields *(mandatory)* ## Spec Scope Fields *(mandatory)*
- **Scope**: [workspace | tenant | canonical-view] - **Scope**: [workspace | tenant | canonical-view]

View File

@ -10,7 +10,6 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionClassificationResult; use App\Services\Providers\ProviderConnectionClassificationResult;
use App\Services\Providers\ProviderConnectionClassifier; use App\Services\Providers\ProviderConnectionClassifier;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialKind; use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource; use App\Support\Providers\ProviderCredentialSource;
@ -29,10 +28,8 @@ class ClassifyProviderConnections extends Command
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.'; protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
public function handle( public function handle(ProviderConnectionClassifier $classifier): int
ProviderConnectionClassifier $classifier, {
ProviderConnectionStateProjector $stateProjector,
): int {
$query = $this->query(); $query = $this->query();
$write = (bool) $this->option('write'); $write = (bool) $this->option('write');
$chunkSize = max(1, (int) $this->option('chunk')); $chunkSize = max(1, (int) $this->option('chunk'));
@ -62,7 +59,6 @@ public function handle(
->orderBy('id') ->orderBy('id')
->chunkById($chunkSize, function ($connections) use ( ->chunkById($chunkSize, function ($connections) use (
$classifier, $classifier,
$stateProjector,
$write, $write,
$tenantCounts, $tenantCounts,
&$startedTenants, &$startedTenants,
@ -101,7 +97,7 @@ public function handle(
$startedTenants[$tenantKey] = true; $startedTenants[$tenantKey] = true;
} }
$connection = $this->applyClassification($connection, $result, $stateProjector); $connection = $this->applyClassification($connection, $result);
$this->auditApplied($tenant, $connection, $result); $this->auditApplied($tenant, $connection, $result);
$appliedCount++; $appliedCount++;
} }
@ -146,11 +142,10 @@ private function query(): Builder
private function applyClassification( private function applyClassification(
ProviderConnection $connection, ProviderConnection $connection,
ProviderConnectionClassificationResult $result, ProviderConnectionClassificationResult $result,
ProviderConnectionStateProjector $stateProjector,
): ProviderConnection { ): ProviderConnection {
DB::transaction(function () use ($connection, $result, $stateProjector): void { DB::transaction(function () use ($connection, $result): void {
$connection->forceFill( $connection->forceFill(
$connection->classificationProjection($result, $stateProjector) $connection->classificationProjection($result)
)->save(); )->save();
$credential = $connection->credential; $credential = $connection->credential;

View File

@ -15,6 +15,7 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails; use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats; use App\Support\Baselines\BaselineCompareStats;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -109,6 +110,13 @@ class BaselineCompareLanding extends Page
/** @var array<string, mixed>|null */ /** @var array<string, mixed>|null */
public ?array $summaryAssessment = null; public ?array $summaryAssessment = null;
/** @var array<string, mixed>|null */
public ?array $navigationContextPayload = null;
public ?int $matrixBaselineProfileId = null;
public ?string $matrixSubjectKey = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = auth()->user(); $user = auth()->user();
@ -130,6 +138,12 @@ public static function canAccess(): bool
public function mount(): void public function mount(): void
{ {
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$baselineProfileId = request()->query('baseline_profile_id');
$subjectKey = request()->query('subject_key');
$this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null;
$this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->refreshStats(); $this->refreshStats();
} }
@ -244,6 +258,9 @@ protected function getViewData(): array
} }
return [ return [
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
'matrixSubjectKey' => $this->matrixSubjectKey,
'hasCoverageWarnings' => $hasCoverageWarnings, 'hasCoverageWarnings' => $hasCoverageWarnings,
'evidenceGapsCountValue' => $evidenceGapsCountValue, 'evidenceGapsCountValue' => $evidenceGapsCountValue,
'hasEvidenceGaps' => $hasEvidenceGaps, 'hasEvidenceGaps' => $hasEvidenceGaps,
@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
$this->compareNowAction(), $navigationContext = $this->navigationContext();
];
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('backToOrigin')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
}
$actions[] = $this->compareNowAction();
return $actions;
} }
private function compareNowAction(): Action private function compareNowAction(): Action
@ -389,7 +416,7 @@ private function compareNowAction(): Action
->actions($run instanceof OperationRun ? [ ->actions($run instanceof OperationRun ? [
Action::make('view_run') Action::make('view_run')
->label('Open operation') ->label('Open operation')
->url(OperationRunLinks::view($run, $tenant)), ->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
] : []) ] : [])
->send(); ->send();
}); });
@ -436,4 +463,15 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats
return $aggregate; return $aggregate;
} }
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
return CanonicalNavigationContext::fromRequest($request);
}
} }

View File

@ -0,0 +1,756 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class BaselineCompareMatrix extends Page implements HasForms
{
use InteractsWithForms;
use InteractsWithRecord;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string $resource = BaselineProfileResource::class;
protected static ?string $breadcrumb = 'Compare matrix';
protected string $view = 'filament.pages.baseline-compare-matrix';
public string $requestedMode = 'auto';
/**
* @var list<string>
*/
public array $selectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $selectedStates = [];
/**
* @var list<string>
*/
public array $selectedSeverities = [];
public string $tenantSort = 'tenant_name';
public string $subjectSort = 'deviation_breadth';
public ?string $focusedSubjectKey = null;
/**
* @var list<string>
*/
public array $draftSelectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $draftSelectedStates = [];
/**
* @var list<string>
*/
public array $draftSelectedSeverities = [];
public string $draftTenantSort = 'tenant_name';
public string $draftSubjectSort = 'deviation_breadth';
/**
* @var array<string, mixed>
*/
public array $matrix = [];
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
}
public function mount(int|string $record): void
{
$this->record = $this->resolveRecord($record);
$this->hydrateFiltersFromRequest();
$this->refreshMatrix();
$this->form->fill($this->filterFormState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 2,
])
->schema([
Grid::make([
'default' => 1,
'lg' => 5,
])
->schema([
Select::make('draftSelectedPolicyTypes')
->label('Policy types')
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All policy types')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.'
: null)
->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter',
])
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedStates')
->label('Technical states')
->options(fn (): array => $this->matrixOptions('stateOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All technical states')
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedSeverities')
->label('Severity')
->options(fn (): array => $this->matrixOptions('severityOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All severities'),
])
->columnSpan([
'xl' => 1,
]),
Grid::make([
'default' => 1,
'md' => 2,
'xl' => 1,
])
->schema([
Select::make('draftTenantSort')
->label('Tenant sort')
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
->default('tenant_name')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
Select::make('draftSubjectSort')
->label('Subject sort')
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
->default('deviation_breadth')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
])
->columnSpan([
'xl' => 1,
]),
]),
]);
}
protected function authorizeAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
public function getTitle(): string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return 'Compare matrix: '.$profile->name;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$profile = $this->getRecord();
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(fn (): mixed => $this->compareAssignedTenants());
$compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction(
$compareAssignedTenantsAction,
fn (): ?Workspace => $this->workspace(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
return [
Action::make('backToBaselineProfile')
->label('Back to baseline profile')
->color('gray')
->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')),
$compareAssignedTenantsAction,
];
}
public function applyFilters(): void
{
$this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes);
$this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates);
$this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities);
$this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort);
$this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort);
$this->redirect($this->filterUrl(), navigate: true);
}
public function resetFilters(): void
{
$this->selectedPolicyTypes = [];
$this->selectedStates = [];
$this->selectedSeverities = [];
$this->tenantSort = 'tenant_name';
$this->subjectSort = 'deviation_breadth';
$this->focusedSubjectKey = null;
$this->draftSelectedPolicyTypes = [];
$this->draftSelectedStates = [];
$this->draftSelectedSeverities = [];
$this->draftTenantSort = 'tenant_name';
$this->draftSubjectSort = 'deviation_breadth';
$this->redirect($this->filterUrl(), navigate: true);
}
public function refreshMatrix(): void
{
$user = auth()->user();
abort_unless($user instanceof User, 403);
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [
'policyTypes' => $this->selectedPolicyTypes,
'states' => $this->selectedStates,
'severities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
'focusedSubjectKey' => $this->focusedSubjectKey,
]);
}
public function pollMatrix(): void
{
$this->refreshMatrix();
}
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return BaselineCompareLanding::getUrl(
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
panel: 'tenant',
tenant: $tenant,
);
}
public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl(
'view',
[
'record' => $findingId,
...$this->navigationContext($tenant, $subjectKey)->toQuery(),
],
tenant: $tenant,
);
}
public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string
{
return OperationRunLinks::tenantlessView(
$runId,
$this->navigationContext(
$tenantId !== null ? $this->tenant($tenantId) : null,
$subjectKey,
),
);
}
public function clearSubjectFocusUrl(): string
{
return static::getUrl($this->routeParameters([
'subject_key' => null,
]), panel: 'admin');
}
public function modeUrl(string $mode): string
{
return $this->filterUrl([
'mode' => $this->normalizeRequestedMode($mode),
]);
}
public function filterUrl(array $overrides = []): string
{
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
}
public function activeFilterCount(): int
{
return count($this->selectedPolicyTypes)
+ count($this->selectedStates)
+ count($this->selectedSeverities)
+ ($this->focusedSubjectKey !== null ? 1 : 0);
}
public function hasStagedFilterChanges(): bool
{
return $this->draftFilterState() !== $this->appliedFilterState();
}
public function canUseCompactMode(): bool
{
return $this->visibleTenantCount() <= 1;
}
public function presentationModeLabel(string $mode): string
{
return match ($mode) {
'dense' => 'Dense mode',
'compact' => 'Compact mode',
default => 'Auto mode',
};
}
/**
* @return array<string, int|string>
*/
public function activeFilterSummary(): array
{
$summary = [];
if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes);
}
if ($this->selectedStates !== []) {
$summary['Technical states'] = count($this->selectedStates);
}
if ($this->selectedSeverities !== []) {
$summary['Severity'] = count($this->selectedSeverities);
}
if ($this->focusedSubjectKey !== null) {
$summary['Focused subject'] = $this->focusedSubjectKey;
}
return $summary;
}
/**
* @return array<string, int|string>
*/
public function stagedFilterSummary(): array
{
$summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
}
if ($this->draftSelectedStates !== $this->selectedStates) {
$summary['Technical states'] = count($this->draftSelectedStates);
}
if ($this->draftSelectedSeverities !== $this->selectedSeverities) {
$summary['Severity'] = count($this->draftSelectedSeverities);
}
if ($this->draftTenantSort !== $this->tenantSort) {
$summary['Tenant sort'] = $this->draftTenantSort;
}
if ($this->draftSubjectSort !== $this->subjectSort) {
$summary['Subject sort'] = $this->draftSubjectSort;
}
return $summary;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
return array_merge($this->matrix, [
'profile' => $this->getRecord(),
'currentFilters' => [
'mode' => $this->requestedMode,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort,
'subject_sort' => $this->subjectSort,
'subject_key' => $this->focusedSubjectKey,
],
'draftFilters' => [
'policy_type' => $this->draftSelectedPolicyTypes,
'state' => $this->draftSelectedStates,
'severity' => $this->draftSelectedSeverities,
'tenant_sort' => $this->draftTenantSort,
'subject_sort' => $this->draftSubjectSort,
],
'presentationState' => $this->presentationState(),
]);
}
private function hydrateFiltersFromRequest(): void
{
$this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto'));
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
$this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name'));
$this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth'));
$subjectKey = request()->query('subject_key');
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->draftSelectedPolicyTypes = $this->selectedPolicyTypes;
$this->draftSelectedStates = $this->selectedStates;
$this->draftSelectedSeverities = $this->selectedSeverities;
$this->draftTenantSort = $this->tenantSort;
$this->draftSubjectSort = $this->subjectSort;
}
/**
* @return array<string, mixed>
*/
private function filterFormState(): array
{
return [
'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes,
'draftSelectedStates' => $this->draftSelectedStates,
'draftSelectedSeverities' => $this->draftSelectedSeverities,
'draftTenantSort' => $this->draftTenantSort,
'draftSubjectSort' => $this->draftSubjectSort,
];
}
/**
* @return array<string, string>
*/
private function matrixOptions(string $key): array
{
$options = $this->matrix[$key] ?? null;
return is_array($options) ? $options : [];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function draftFilterState(): array
{
return [
'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes),
'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates),
'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities),
'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort),
'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort),
];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function appliedFilterState(): array
{
return [
'selectedPolicyTypes' => $this->selectedPolicyTypes,
'selectedStates' => $this->selectedStates,
'selectedSeverities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
];
}
/**
* @return list<string>
*/
private function normalizeQueryList(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string {
if (! is_string($item)) {
return null;
}
$normalized = trim($item);
return $normalized !== '' ? $normalized : null;
}, $values))));
}
private function normalizeRequestedMode(mixed $value): string
{
return in_array((string) $value, ['auto', 'dense', 'compact'], true)
? (string) $value
: 'auto';
}
private function normalizeTenantSort(mixed $value): string
{
return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true)
? (string) $value
: 'tenant_name';
}
private function normalizeSubjectSort(mixed $value): string
{
return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true)
? (string) $value
: 'deviation_breadth';
}
private function compareAssignedTenantsDisabledReason(): ?string
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
if (($reference['referenceState'] ?? null) !== 'ready') {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function compareAssignedTenants(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(
context: $this->navigationContext(),
allTenants: true,
)),
])
->send();
} else {
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
}
$this->refreshMatrix();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function routeParameters(array $overrides = []): array
{
return array_filter([
'record' => $this->getRecord(),
'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null,
'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null,
'subject_key' => $this->focusedSubjectKey,
...$overrides,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
}
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$subjectKey ??= $this->focusedSubjectKey;
return CanonicalNavigationContext::forBaselineCompareMatrix(
profile: $profile,
filters: $this->routeParameters(),
tenant: $tenant,
subjectKey: $subjectKey,
);
}
private function tenant(int $tenantId): ?Tenant
{
return Tenant::query()
->whereKey($tenantId)
->where('workspace_id', (int) $this->getRecord()->workspace_id)
->first();
}
private function workspace(): ?Workspace
{
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
}
/**
* @return array<string, mixed>
*/
private function presentationState(): array
{
$resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount());
return [
'requestedMode' => $this->requestedMode,
'resolvedMode' => $resolvedMode,
'visibleTenantCount' => $this->visibleTenantCount(),
'activeFilterCount' => $this->activeFilterCount(),
'hasStagedFilterChanges' => $this->hasStagedFilterChanges(),
'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false),
'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null,
'canOverrideMode' => $this->visibleTenantCount() > 0,
'compactModeAvailable' => $this->canUseCompactMode(),
];
}
private function visibleTenantCount(): int
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
return (int) ($reference['visibleTenantCount'] ?? 0);
}
private function resolvePresentationMode(int $visibleTenantCount): string
{
if ($this->requestedMode === 'dense') {
return 'dense';
}
if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) {
return 'compact';
}
return $visibleTenantCount > 1 ? 'dense' : 'compact';
}
}

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingLifecycleService; use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService; use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Services\Providers\ProviderOperationRegistry; use App\Services\Providers\ProviderOperationRegistry;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
@ -2535,12 +2534,6 @@ public function createProviderConnection(array $data): void
/** @var ProviderConnection $connection */ /** @var ProviderConnection $connection */
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection { $connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection {
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
$connection = ProviderConnection::query() $connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft') ->where('provider', 'microsoft')
@ -2554,15 +2547,14 @@ public function createProviderConnection(array $data): void
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => $displayName, 'display_name' => $displayName,
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,

View File

@ -4,6 +4,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\BaselineProfileResource\Pages; use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
@ -25,6 +26,9 @@
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -43,6 +47,7 @@
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@ -135,7 +140,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one state-sensitive primary action, moves snapshot and compare-matrix navigation into contextual related context, and groups secondary actions under "More".');
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
@ -318,6 +323,15 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (BaselineProfile $record): array => self::detailRelatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Metadata') Section::make('Metadata')
->schema([ ->schema([
TextEntry::make('createdByUser.name') TextEntry::make('createdByUser.name')
@ -333,6 +347,37 @@ public static function infolist(Schema $schema): Schema
]); ]);
} }
/**
* @return array<int, array<string, mixed>>
*/
public static function detailRelatedContextEntries(BaselineProfile $record): array
{
$entries = [];
$snapshotEntry = app(RelatedNavigationResolver::class)
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $record)[0] ?? null;
if ($snapshotEntry instanceof RelatedContextEntry) {
$entries[] = $snapshotEntry->toArray();
}
$entries[] = RelatedContextEntry::available(
key: 'compare_matrix',
label: 'Compare matrix',
value: 'Review compare matrix',
secondaryValue: $record->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot
? 'Use the latest consumable snapshot to inspect compare outcomes.'
: 'Open the matrix to inspect compare readiness and previous results.',
targetUrl: self::compareMatrixUrl($record),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open compare matrix',
contextBadge: 'Comparison',
)->toArray();
return $entries;
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
$workspace = self::resolveWorkspace(); $workspace = self::resolveWorkspace();
@ -447,10 +492,16 @@ public static function getPages(): array
'index' => Pages\ListBaselineProfiles::route('/'), 'index' => Pages\ListBaselineProfiles::route('/'),
'create' => Pages\CreateBaselineProfile::route('/create'), 'create' => Pages\CreateBaselineProfile::route('/create'),
'view' => Pages\ViewBaselineProfile::route('/{record}'), 'view' => Pages\ViewBaselineProfile::route('/{record}'),
'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'),
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'), 'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
]; ];
} }
public static function compareMatrixUrl(BaselineProfile|int $profile): string
{
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -16,15 +16,13 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -37,24 +35,19 @@ class ViewBaselineProfile extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Action::make('view_active_snapshot')
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
->color('gray'),
$this->captureAction(), $this->captureAction(),
$this->compareNowAction(), $this->compareNowAction(),
EditAction::make() ActionGroup::make([
->visible(fn (): bool => $this->hasManageCapability()), $this->compareAssignedTenantsAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
])
->label('More')
->icon('heroicon-m-ellipsis-vertical')
->color('gray'),
]; ];
} }
private function activeSnapshotEntry(): ?RelatedContextEntry
{
return app(RelatedNavigationResolver::class)
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
}
private function captureAction(): Action private function captureAction(): Action
{ {
/** @var BaselineProfile $profile */ /** @var BaselineProfile $profile */
@ -76,6 +69,7 @@ private function captureAction(): Action
->label($label) ->label($label)
->icon('heroicon-o-camera') ->icon('heroicon-o-camera')
->color('primary') ->color('primary')
->hidden(fn (): bool => $this->profileHasConsumableSnapshot())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($label) ->modalHeading($label)
->modalDescription($modalDescription) ->modalDescription($modalDescription)
@ -188,6 +182,8 @@ private function compareNowAction(): Action
return Action::make('compareNow') return Action::make('compareNow')
->label($label) ->label($label)
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('primary')
->hidden(fn (): bool => ! $this->profileHasConsumableSnapshot())
->requiresConfirmation() ->requiresConfirmation()
->modalHeading($label) ->modalHeading($label)
->modalDescription($modalDescription) ->modalDescription($modalDescription)
@ -307,6 +303,71 @@ private function compareNowAction(): Action
}); });
} }
private function compareAssignedTenantsAction(): Action
{
$action = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(allTenants: true)),
])
->send();
return;
}
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
});
return WorkspaceUiEnforcement::forAction(
$action,
fn (): ?Workspace => Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@ -407,4 +468,48 @@ private function profileHasConsumableSnapshot(): bool
return $profile->resolveCurrentConsumableSnapshot() !== null; return $profile->resolveCurrentConsumableSnapshot() !== null;
} }
private function compareAssignedTenantsDisabledReason(): ?string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
if (! $this->profileHasConsumableSnapshot()) {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ($this->visibleAssignedTenantCount($profile) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function visibleAssignedTenantCount(BaselineProfile $profile): int
{
$user = auth()->user();
if (! $user instanceof User) {
return 0;
}
$tenantIds = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->pluck('tenant_id')
->all();
if ($tenantIds === []) {
return 0;
}
$resolver = app(CapabilityResolver::class);
return Tenant::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('id', $tenantIds)
->get(['id'])
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
->count();
}
} }

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\EvidenceSnapshotResource\Pages; use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Models\Tenant; use App\Models\Tenant;
@ -18,6 +19,7 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -115,7 +117,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence as the primary action, keeps Expire snapshot visibly separated as danger, and renders operation/review-pack navigation in contextual related context.');
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
@ -181,6 +183,15 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'), TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
]) ])
->columns(2), ->columns(2),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (EvidenceSnapshot $record): array => static::relatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Evidence dimensions') Section::make('Evidence dimensions')
->schema([ ->schema([
RepeatableEntry::make('items') RepeatableEntry::make('items')
@ -213,6 +224,48 @@ public static function infolist(Schema $schema): Schema
]); ]);
} }
/**
* @return array<int, array<string, mixed>>
*/
public static function relatedContextEntries(EvidenceSnapshot $record): array
{
$entries = [];
if (is_numeric($record->operation_run_id)) {
$entries[] = RelatedContextEntry::available(
key: 'operation_run',
label: 'Operation',
value: sprintf('#%d', (int) $record->operation_run_id),
secondaryValue: 'Open the latest evidence refresh operation.',
targetUrl: OperationRunLinks::tenantlessView((int) $record->operation_run_id),
targetKind: 'canonical_page',
priority: 10,
actionLabel: OperationRunLinks::openLabel(),
contextBadge: 'Operations',
)->toArray();
}
$pack = $record->reviewPacks()
->latest('created_at')
->first();
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'review_pack',
label: 'Review pack',
value: sprintf('#%d', (int) $pack->getKey()),
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
targetKind: 'direct_record',
priority: 20,
actionLabel: 'View review pack',
contextBadge: 'Reporting',
)->toArray();
}
return $entries;
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table

View File

@ -5,12 +5,9 @@
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages; namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Models\ReviewPack;
use App\Models\User; use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -29,30 +26,11 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->icon('heroicon-o-eye')
->color('gray')
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
Actions\Action::make('view_review_pack')
->label('View review pack')
->icon('heroicon-o-document-text')
->color('gray')
->url(function (): ?string {
$pack = $this->latestReviewPack();
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
return null;
}
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
})
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('refresh_snapshot') Actions\Action::make('refresh_snapshot')
->label('Refresh evidence') ->label('Refresh evidence')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->action(function (): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
@ -92,11 +70,4 @@ protected function getHeaderActions(): array
->apply(), ->apply(),
]; ];
} }
private function latestReviewPack(): ?ReviewPack
{
return $this->record->reviewPacks()
->latest('created_at')
->first();
}
} }

View File

@ -7,6 +7,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\FindingExceptionResource\Pages; use App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\FindingExceptionEvidenceReference; use App\Models\FindingExceptionEvidenceReference;
use App\Models\Tenant; use App\Models\Tenant;
@ -20,6 +21,7 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -34,6 +36,7 @@
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@ -115,7 +118,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
@ -217,6 +220,15 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(3), ->columns(3),
]), ]),
Section::make('Related context')
->schema([
ViewEntry::make('related_context')
->label('')
->view('filament.infolists.entries.related-context')
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Evidence references') Section::make('Evidence references')
->schema([ ->schema([
RepeatableEntry::make('evidenceReferences') RepeatableEntry::make('evidenceReferences')
@ -245,6 +257,44 @@ public static function infolist(Schema $schema): Schema
]); ]);
} }
/**
* @return array<int, array<string, mixed>>
*/
public static function relatedContextEntries(FindingException $record): array
{
$entries = [];
if ($record->finding && $record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'finding',
label: 'Finding',
value: static::findingSummary($record),
secondaryValue: 'Return to the linked finding detail.',
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
targetKind: 'direct_record',
priority: 10,
actionLabel: 'Open finding',
contextBadge: 'Governance',
)->toArray();
}
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
$entries[] = RelatedContextEntry::available(
key: 'approval_queue',
label: 'Approval queue',
value: 'Review pending exception requests',
secondaryValue: 'Return to the queue for the rest of this tenants governance workload.',
targetUrl: static::approvalQueueUrl($record->tenant),
targetKind: 'canonical_page',
priority: 20,
actionLabel: 'Open approval queue',
contextBadge: 'Queue',
)->toArray();
}
return $entries;
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table

View File

@ -5,7 +5,6 @@
namespace App\Filament\Resources\FindingExceptionResource\Pages; namespace App\Filament\Resources\FindingExceptionResource\Pages;
use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -34,40 +33,10 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Action::make('open_finding')
->label('Open finding')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(function (): ?string {
$record = $this->getRecord();
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
return null;
}
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
}),
Action::make('open_approval_queue')
->label('Open approval queue')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(function (): bool {
$record = $this->getRecord();
return $record instanceof FindingException
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
})
->url(function (): ?string {
$record = $this->getRecord();
return $record instanceof FindingException
? FindingExceptionResource::approvalQueueUrl($record->tenant)
: null;
}),
Action::make('renew_exception') Action::make('renew_exception')
->label('Renew exception') ->label('Renew exception')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('primary')
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed()) ->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
->fillForm(fn (): array => [ ->fillForm(fn (): array => [
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null, 'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,

View File

@ -1257,6 +1257,16 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
{ {
$incomingContext = CanonicalNavigationContext::fromRequest(request());
if (
$incomingContext instanceof CanonicalNavigationContext
&& str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix')
&& $incomingContext->backLinkUrl !== null
) {
return $incomingContext;
}
$tenant = $record->tenant; $tenant = $record->tenant;
return new CanonicalNavigationContext( return new CanonicalNavigationContext(

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use Filament\Actions; use Filament\Actions;
@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Actions\Action::make('back_to_origin')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
}
return array_merge($actions, [
Actions\Action::make('primary_related') Actions\Action::make('primary_related')
->label(fn (): string => app(RelatedNavigationResolver::class) ->label(fn (): string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record') ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
@ -53,11 +64,16 @@ protected function getHeaderActions(): array
->label('Actions') ->label('Actions')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]; ]);
} }
public function getSubheading(): string|Htmlable|null public function getSubheading(): string|Htmlable|null
{ {
return FindingResource::findingSubheading($this->getRecord()); return FindingResource::findingSubheading($this->getRecord());
} }
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
} }

View File

@ -472,6 +472,11 @@ private static function consentStatusLabelFromState(mixed $state): string
return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label;
} }
private static function lifecycleLabelFromState(mixed $state): string
{
return BadgeRenderer::spec(BadgeDomain::BooleanEnabled, $state)->label;
}
private static function verificationStatusLabelFromState(mixed $state): string private static function verificationStatusLabelFromState(mixed $state): string
{ {
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
@ -512,6 +517,9 @@ public static function form(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Current state') Section::make('Current state')
->schema([ ->schema([
Placeholder::make('is_enabled_display')
->label('Lifecycle')
->content(fn (?ProviderConnection $record): string => static::lifecycleLabelFromState($record?->is_enabled)),
Placeholder::make('consent_status_display') Placeholder::make('consent_status_display')
->label('Consent') ->label('Consent')
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)), ->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
@ -526,12 +534,6 @@ public static function form(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Diagnostics') Section::make('Diagnostics')
->schema([ ->schema([
Placeholder::make('status_display')
->label('Legacy status')
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label),
Placeholder::make('health_status_display')
->label('Legacy health')
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label),
Placeholder::make('migration_review_status_display') Placeholder::make('migration_review_status_display')
->label('Migration review') ->label('Migration review')
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record)) ->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
@ -578,6 +580,13 @@ public static function infolist(Schema $schema): Schema
->columns(2), ->columns(2),
Section::make('Current state') Section::make('Current state')
->schema([ ->schema([
Infolists\Components\TextEntry::make('is_enabled')
->label('Lifecycle')
->badge()
->formatStateUsing(fn ($state): string => static::lifecycleLabelFromState($state))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
Infolists\Components\TextEntry::make('consent_status') Infolists\Components\TextEntry::make('consent_status')
->label('Consent') ->label('Consent')
->badge() ->badge()
@ -599,20 +608,6 @@ public static function infolist(Schema $schema): Schema
->columns(2), ->columns(2),
Section::make('Diagnostics') Section::make('Diagnostics')
->schema([ ->schema([
Infolists\Components\TextEntry::make('status')
->label('Legacy status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
Infolists\Components\TextEntry::make('health_status')
->label('Legacy health')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Infolists\Components\TextEntry::make('migration_review_required') Infolists\Components\TextEntry::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
@ -684,6 +679,13 @@ public static function table(Table $table): Table
? 'Dedicated' ? 'Dedicated'
: 'Platform') : 'Platform')
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'), ->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
Tables\Columns\TextColumn::make('is_enabled')
->label('Lifecycle')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
Tables\Columns\TextColumn::make('consent_status') Tables\Columns\TextColumn::make('consent_status')
->label('Consent') ->label('Consent')
->badge() ->badge()
@ -698,22 +700,6 @@ public static function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
Tables\Columns\TextColumn::make('status')
->label('Legacy status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('health_status')
->label('Legacy health')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('migration_review_required') Tables\Columns\TextColumn::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->badge() ->badge()
@ -796,12 +782,10 @@ public static function table(Table $table): Table
return $query->where('provider_connections.verification_status', $value); return $query->where('provider_connections.verification_status', $value);
}), }),
SelectFilter::make('status') SelectFilter::make('is_enabled')
->label('Diagnostic status') ->label('Lifecycle')
->options([ ->options([
'connected' => 'Connected', 'enabled' => 'Enabled',
'needs_consent' => 'Needs consent',
'error' => 'Error',
'disabled' => 'Disabled', 'disabled' => 'Disabled',
]) ])
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
@ -811,24 +795,7 @@ public static function table(Table $table): Table
return $query; return $query;
} }
return $query->where('provider_connections.status', $value); return $query->where('provider_connections.is_enabled', $value === 'enabled');
}),
SelectFilter::make('health_status')
->label('Diagnostic health')
->options([
'ok' => 'OK',
'degraded' => 'Degraded',
'down' => 'Down',
'unknown' => 'Unknown',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('provider_connections.health_status', $value);
}), }),
Filter::make('default_only') Filter::make('default_only')
->label('Default only') ->label('Default only')
@ -847,7 +814,7 @@ public static function table(Table $table): Table
->label('Check connection') ->label('Check connection')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -946,7 +913,7 @@ public static function table(Table $table): Table
->label('Inventory sync') ->label('Inventory sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -1043,7 +1010,7 @@ public static function table(Table $table): Table
->label('Compliance snapshot') ->label('Compliance snapshot')
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -1141,7 +1108,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
@ -1383,7 +1350,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
@ -1392,15 +1359,14 @@ public static function table(Table $table): Table
} }
$hadCredentials = $record->credential()->exists(); $hadCredentials = $record->credential()->exists();
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$verificationStatus = $hadCredentials ? ProviderVerificationStatus::Unknown : ProviderVerificationStatus::Blocked;
$status = $hadCredentials ? 'connected' : 'error';
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
$record->update([ $record->update([
'status' => $status, 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => $errorReasonCode, 'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage, 'last_error_message' => $errorMessage,
@ -1418,8 +1384,9 @@ public static function table(Table $table): Table
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_status' => $status, 'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials, 'credentials_present' => $hadCredentials,
], ],
], ],
@ -1457,7 +1424,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
@ -1465,10 +1432,10 @@ public static function table(Table $table): Table
return; return;
} }
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$record->update([ $record->update([
'status' => 'disabled', 'is_enabled' => false,
]); ]);
$user = auth()->user(); $user = auth()->user();
@ -1483,7 +1450,8 @@ public static function table(Table $table): Table
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
], ],
], ],
actorId: $actorId, actorId: $actorId,

View File

@ -6,7 +6,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -30,27 +29,20 @@ protected function mutateFormDataBeforeCreate(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
return [ return [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'], 'entra_tenant_id' => $data['entra_tenant_id'],
'display_name' => $data['display_name'], 'display_name' => $data['display_name'],
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,

View File

@ -212,7 +212,7 @@ protected function getHeaderActions(): array
return $tenant instanceof Tenant return $tenant instanceof Tenant
&& $user instanceof User && $user instanceof User
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && (bool) $record->is_enabled;
}) })
->action(function (ProviderConnection $record, StartVerification $verification): void { ->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -521,7 +521,7 @@ protected function getHeaderActions(): array
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->status !== 'disabled' && (bool) $record->is_enabled
&& ! $record->is_default && ! $record->is_default
&& ProviderConnection::query() && ProviderConnection::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
@ -581,7 +581,7 @@ protected function getHeaderActions(): array
return $tenant instanceof Tenant return $tenant instanceof Tenant
&& $user instanceof User && $user instanceof User
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && (bool) $record->is_enabled;
}) })
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -695,7 +695,7 @@ protected function getHeaderActions(): array
return $tenant instanceof Tenant return $tenant instanceof Tenant
&& $user instanceof User && $user instanceof User
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && (bool) $record->is_enabled;
}) })
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -803,7 +803,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') ->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -812,15 +812,19 @@ protected function getHeaderActions(): array
} }
$hadCredentials = $record->credential()->exists(); $hadCredentials = $record->credential()->exists();
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$verificationStatus = $hadCredentials ? \App\Support\Providers\ProviderVerificationStatus::Unknown : \App\Support\Providers\ProviderVerificationStatus::Blocked;
$status = $hadCredentials ? 'connected' : 'needs_consent';
$errorReasonCode = null; $errorReasonCode = null;
$errorMessage = null; $errorMessage = null;
if (! $hadCredentials) {
$errorReasonCode = \App\Support\Providers\ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = 'Provider connection credentials are missing.';
}
$record->update([ $record->update([
'status' => $status, 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => $errorReasonCode, 'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage, 'last_error_message' => $errorMessage,
@ -838,8 +842,9 @@ protected function getHeaderActions(): array
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_status' => $status, 'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials, 'credentials_present' => $hadCredentials,
], ],
], ],
@ -853,8 +858,8 @@ protected function getHeaderActions(): array
if (! $hadCredentials) { if (! $hadCredentials) {
Notification::make() Notification::make()
->title('Connection enabled (needs consent)') ->title('Connection enabled (credentials missing)')
->body('Grant admin consent before running checks or operations.') ->body('Add credentials before running checks or operations.')
->warning() ->warning()
->send(); ->send();
@ -878,7 +883,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -886,10 +891,10 @@ protected function getHeaderActions(): array
return; return;
} }
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$record->update([ $record->update([
'status' => 'disabled', 'is_enabled' => false,
]); ]);
$user = auth()->user(); $user = auth()->user();
@ -904,7 +909,8 @@ protected function getHeaderActions(): array
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
], ],
], ],
actorId: $actorId, actorId: $actorId,

File diff suppressed because it is too large Load Diff

View File

@ -18,49 +18,54 @@ class EditTenant extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return array_values(array_filter([
Actions\ViewAction::make(), Actions\ActionGroup::make([
Actions\Action::make('related_onboarding') UiEnforcement::forAction(
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding') Action::make('restore')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye') ->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->color('success')
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor), ->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
UiEnforcement::forAction( ->requiresConfirmation()
Action::make('restore') ->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore') ->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->color('success') ->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left') ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
->requiresConfirmation() TenantResource::restoreTenant($record, $auditLogger);
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant') })
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.') )
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore') ->requireCapability(Capabilities::TENANT_DELETE)
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { ->tooltip('You do not have permission to restore tenants.')
TenantResource::restoreTenant($record, $auditLogger); ->preserveVisibility()
}) ->destructive()
) ->apply(),
->requireCapability(Capabilities::TENANT_DELETE) UiEnforcement::forAction(
->tooltip('You do not have permission to restore tenants.') Action::make('archive')
->preserveVisibility() ->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
->destructive() ->color('danger')
->apply(), ->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
UiEnforcement::forAction( ->requiresConfirmation()
Action::make('archive') ->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive') ->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->color('danger') ->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark') ->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
->requiresConfirmation() TenantResource::archiveTenant($record, $auditLogger);
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant') })
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.') )
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive') ->requireCapability(Capabilities::TENANT_DELETE)
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void { ->tooltip('You do not have permission to archive tenants.')
TenantResource::archiveTenant($record, $auditLogger); ->preserveVisibility()
}) ->destructive()
) ->apply(),
->requireCapability(Capabilities::TENANT_DELETE) ])
->tooltip('You do not have permission to archive tenants.') ->label('Lifecycle')
->preserveVisibility() ->icon('heroicon-o-archive-box')
->destructive() ->color('gray')
->apply(), ->visible(fn (): bool => $this->getRecord() instanceof Tenant
]; && in_array(
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
['archive', 'restore'],
true,
)),
]));
} }
} }

View File

@ -56,7 +56,7 @@ protected function getTableEmptyStateHeading(): ?string
protected function getTableEmptyStateDescription(): ?string protected function getTableEmptyStateDescription(): ?string
{ {
if ($this->hasActiveTriageEmptyState()) { if ($this->hasActiveTriageEmptyState()) {
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.'; return 'Try a different backup posture, recovery evidence, or review-state filter, or return to the default calm-browsing order.';
} }
return parent::getTableEmptyStateDescription(); return parent::getTableEmptyStateDescription();
@ -85,6 +85,7 @@ private function applyRequestedTriageIntent(): void
{ {
$hasIntent = request()->query->has('backup_posture') $hasIntent = request()->query->has('backup_posture')
|| request()->query->has('recovery_evidence') || request()->query->has('recovery_evidence')
|| request()->query->has('review_state')
|| request()->query->has('triage_sort'); || request()->query->has('triage_sort');
if (! $hasIntent) { if (! $hasIntent) {
@ -93,9 +94,10 @@ private function applyRequestedTriageIntent(): void
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture')); $backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence')); $recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
$reviewStates = TenantResource::sanitizeReviewStates(request()->query('review_state'));
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort')); $triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) { foreach (['backup_posture', 'recovery_evidence', 'review_state', 'triage_sort'] as $filterName) {
data_forget($this->tableFilters, $filterName); data_forget($this->tableFilters, $filterName);
data_forget($this->tableDeferredFilters, $filterName); data_forget($this->tableDeferredFilters, $filterName);
} }
@ -110,6 +112,11 @@ private function applyRequestedTriageIntent(): void
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence; $this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
} }
if ($reviewStates !== []) {
$this->tableFilters['review_state']['values'] = $reviewStates;
$this->tableDeferredFilters['review_state']['values'] = $reviewStates;
}
if ($triageSort !== null) { if ($triageSort !== null) {
$this->tableFilters['triage_sort']['value'] = $triageSort; $this->tableFilters['triage_sort']['value'] = $triageSort;
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort; $this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
@ -122,17 +129,19 @@ private function hasActiveTriageEmptyState(): bool
return $state['backup_posture'] !== [] return $state['backup_posture'] !== []
|| $state['recovery_evidence'] !== [] || $state['recovery_evidence'] !== []
|| $state['review_state'] !== []
|| $state['triage_sort'] === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST; || $state['triage_sort'] === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
} }
/** /**
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, triage_sort: string|null} * @return array{backup_posture: list<string>, recovery_evidence: list<string>, review_state: list<string>, triage_sort: string|null}
*/ */
public function currentPortfolioTriageReturnState(): array public function currentPortfolioTriageReturnState(): array
{ {
return [ return [
'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])), 'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])),
'recovery_evidence' => TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])), 'recovery_evidence' => TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])),
'review_state' => TenantResource::sanitizeReviewStates(data_get($this->tableFilters, 'review_state.values', [])),
'triage_sort' => TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')), 'triage_sort' => TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')),
]; ];
} }

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources\TenantResource\Pages; namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget; use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
use App\Filament\Widgets\Tenant\RecentOperationsSummary; use App\Filament\Widgets\Tenant\RecentOperationsSummary;
@ -56,29 +55,8 @@ protected function getHeaderWidgets(): array
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return array_values(array_filter([
Actions\ActionGroup::make([ Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('provider_connections')
->label('Provider connections')
->icon('heroicon-o-link')
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
)
->requireCapability(Capabilities::PROVIDER_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
Actions\Action::make('admin_consent') Actions\Action::make('admin_consent')
->label('Grant admin consent') ->label('Grant admin consent')
->icon('heroicon-o-clipboard-document') ->icon('heroicon-o-clipboard-document')
@ -91,6 +69,13 @@ protected function getHeaderActions(): array
->url(fn (Tenant $record) => TenantResource::entraUrl($record)) ->url(fn (Tenant $record) => TenantResource::entraUrl($record))
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
->openUrlInNewTab(), ->openUrlInNewTab(),
])
->label('External links')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
Actions\ActionGroup::make([
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('verify') Actions\Action::make('verify')
->label(self::verificationHeaderActionLabel()) ->label(self::verificationHeaderActionLabel())
@ -156,10 +141,6 @@ protected function getHeaderActions(): array
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [ $actions = [
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel()) ->label(OperationRunLinks::openLabel())
@ -283,6 +264,13 @@ protected function getHeaderActions(): array
->preserveVisibility() ->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN) ->requireCapability(Capabilities::PROVIDER_RUN)
->apply(), ->apply(),
])
->label('Setup')
->icon('heroicon-o-wrench-screwdriver')
->color('gray')
->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
Actions\ActionGroup::make([
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('restore') Actions\Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore') ->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
@ -318,9 +306,11 @@ protected function getHeaderActions(): array
->destructive() ->destructive()
->apply(), ->apply(),
]) ])
->label('Actions') ->label('Lifecycle')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-archive-box')
->color('gray'), ->color('gray')
]; ->visible(fn (): bool => $this->getRecord() instanceof Tenant
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
]));
} }
} }

View File

@ -122,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes one dominant lifecycle action, groups the remaining lifecycle actions under "More", keeps archive in a danger bucket, and renders operation/export/evidence navigation in contextual summary content.');
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
@ -570,6 +570,7 @@ private static function summaryPresentation(TenantReview $record): array
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record),
'metrics' => [ 'metrics' => [
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
@ -579,6 +580,43 @@ private static function summaryPresentation(TenantReview $record): array
]; ];
} }
/**
* @return array<int, array{title:string,label:string,url:string,description:string}>
*/
private static function summaryContextLinks(TenantReview $record): array
{
$links = [];
if (is_numeric($record->operation_run_id)) {
$links[] = [
'title' => 'Operation',
'label' => 'Open operation',
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => 'Inspect the latest review composition or refresh run.',
];
}
if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [
'title' => 'Executive pack',
'label' => 'View executive pack',
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => 'Open the current export that belongs to this review.',
];
}
if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [
'title' => 'Evidence snapshot',
'label' => 'View evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => 'Return to the evidence basis behind this review.',
];
}
return $links;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@ -11,7 +11,6 @@
use App\Services\TenantReviews\TenantReviewLifecycleService; use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService; use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
use Filament\Actions; use Filament\Actions;
@ -53,153 +52,235 @@ protected function authorizeAccess(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $secondaryActions = $this->secondaryLifecycleActions();
Actions\Action::make('view_run')
->label('Open operation')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
->url(fn (): ?string => $this->record->operation_run_id
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
: null),
Actions\Action::make('view_export')
->label('View executive pack')
->icon('heroicon-o-document-arrow-down')
->color('gray')
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
->url(fn (): ?string => $this->record->currentExportReviewPack
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
: null),
Actions\Action::make('view_evidence')
->label('View evidence snapshot')
->icon('heroicon-o-shield-check')
->color('gray')
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
->url(fn (): ?string => $this->record->evidenceSnapshot
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
: null),
UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label('Refresh review')
->icon('heroicon-o-arrow-path')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) { return array_values(array_filter([
abort(403); $this->primaryLifecycleAction(),
} Actions\ActionGroup::make($secondaryActions)
try {
app(TenantReviewService::class)->refresh($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
return;
}
Notification::make()->success()->title('Refresh review queued')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label('Publish review')
->icon('heroicon-o-check-badge')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
return;
}
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->hidden(fn (): bool => ! in_array($this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('create_next_review')
->label('Create next review')
->icon('heroicon-o-document-duplicate')
->hidden(fn (): bool => ! $this->record->isPublished())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
return;
}
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label('Archive review')
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
->label('More') ->label('More')
->icon('heroicon-m-ellipsis-vertical') ->icon('heroicon-m-ellipsis-vertical')
->color('gray'), ->color('gray')
]; ->visible(fn (): bool => $secondaryActions !== []),
Actions\ActionGroup::make([
$this->archiveReviewAction(),
])
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
]));
}
private function primaryLifecycleAction(): ?Actions\Action
{
return match ($this->primaryLifecycleActionName()) {
'refresh_review' => $this->refreshReviewAction(),
'publish_review' => $this->publishReviewAction(),
'export_executive_pack' => $this->exportExecutivePackAction(),
default => null,
};
}
private function primaryLifecycleActionName(): ?string
{
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
return 'publish_review';
}
if ($this->record->isMutable()) {
return 'refresh_review';
}
return null;
}
/**
* @return list<Actions\Action>
*/
private function secondaryLifecycleActions(): array
{
return array_values(array_filter(array_map(
fn (string $name): ?Actions\Action => match ($name) {
'refresh_review' => $this->refreshReviewAction(),
'publish_review' => $this->publishReviewAction(),
'export_executive_pack' => $this->exportExecutivePackAction(),
'create_next_review' => $this->createNextReviewAction(),
default => null,
},
$this->secondaryLifecycleActionNames(),
)));
}
/**
* @return array<int, string>
*/
private function secondaryLifecycleActionNames(): array
{
$names = [];
if ($this->record->isMutable()) {
$names[] = 'refresh_review';
$names[] = 'publish_review';
}
if (in_array((string) $this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true)) {
$names[] = 'export_executive_pack';
}
if ($this->record->isPublished()) {
$names[] = 'create_next_review';
}
return array_values(array_filter(
$names,
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
));
}
private function refreshReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label('Refresh review')
->icon('heroicon-o-arrow-path')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewService::class)->refresh($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
return;
}
Notification::make()->success()->title('Refresh review queued')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function publishReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label('Publish review')
->icon('heroicon-o-check-badge')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
return;
}
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function exportExecutivePackAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function createNextReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('create_next_review')
->label('Create next review')
->icon('heroicon-o-document-duplicate')
->hidden(fn (): bool => ! $this->record->isPublished())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
return;
}
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function archiveReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label('Archive review')
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
} }
} }

View File

@ -68,8 +68,14 @@ public function table(Table $table): Table
return Tenant::query() return Tenant::query()
->with('workspace') ->with('workspace')
->withCount([ ->withCount([
'providerConnections', 'providerConnections as critical_provider_connections_count' => fn (Builder $query): Builder => $query
'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'), ->where('provider', 'microsoft')
->where('is_default', true)
->whereIn('verification_status', ['blocked', 'error']),
'providerConnections as warning_provider_connections_count' => fn (Builder $query): Builder => $query
->where('provider', 'microsoft')
->where('is_default', true)
->where('verification_status', 'degraded'),
'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'), 'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'),
]); ]);
}) })
@ -108,11 +114,14 @@ private function healthForTenant(Tenant $tenant): string
return 'unknown'; return 'unknown';
} }
if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) { if ((int) ($tenant->getAttribute('critical_provider_connections_count') ?? 0) > 0) {
return 'critical'; return 'critical';
} }
if ((int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0) { if (
(int) ($tenant->getAttribute('warning_provider_connections_count') ?? 0) > 0
|| (int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0
) {
return 'warn'; return 'warn';
} }

View File

@ -50,7 +50,17 @@ public function providerConnections(): Collection
->where('tenant_id', (int) $this->tenant->getKey()) ->where('tenant_id', (int) $this->tenant->getKey())
->orderByDesc('is_default') ->orderByDesc('is_default')
->orderBy('provider') ->orderBy('provider')
->get(['id', 'provider', 'status', 'health_status', 'is_default', 'last_health_check_at']); ->get([
'id',
'display_name',
'provider',
'is_default',
'is_enabled',
'consent_status',
'verification_status',
'last_health_check_at',
'last_error_reason_code',
]);
} }
/** /**

View File

@ -5,18 +5,51 @@
namespace App\Filament\Widgets\Tenant; namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\PortfolioTriage\TenantTriageReviewService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\PortfolioTriage\PortfolioArrivalContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver; use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
class TenantTriageArrivalContinuity extends Widget class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSchemas
{ {
use InteractsWithActions;
use InteractsWithSchemas;
/**
* @var array<string, mixed>|null
*/
public ?array $arrivalState = null;
protected static bool $isLazy = false; protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.widgets.tenant.triage-arrival-continuity'; protected string $view = 'filament.widgets.tenant.triage-arrival-continuity';
public function mount(): void
{
$this->arrivalState = PortfolioArrivalContextToken::decode(
request()->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -25,11 +58,210 @@ protected function getViewData(): array
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return ['context' => null]; return ['context' => null, 'reviewState' => null];
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return ['context' => null, 'reviewState' => null];
} }
return [ return [
'context' => app(PortfolioArrivalContextResolver::class)->resolve(request(), $tenant), 'context' => $context,
'reviewState' => $this->currentReviewStateFor($tenant, $context->concernFamily),
]; ];
} }
public function markReviewedAction(): Action
{
return UiEnforcement::forAction(
Action::make('markReviewed')
->label('Mark reviewed')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Mark reviewed')
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_REVIEWED))
->visible(fn (): bool => $this->canShowReviewActions())
->action(function (TenantTriageReviewService $service): void {
$this->handleReviewMutation(TenantTriageReview::STATE_REVIEWED, $service);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
->apply();
}
public function markFollowUpNeededAction(): Action
{
return UiEnforcement::forAction(
Action::make('markFollowUpNeeded')
->label('Mark follow-up needed')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->requiresConfirmation()
->modalHeading('Mark follow-up needed')
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_FOLLOW_UP_NEEDED))
->visible(fn (): bool => $this->canShowReviewActions())
->action(function (TenantTriageReviewService $service): void {
$this->handleReviewMutation(TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $service);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
->apply();
}
private function canShowReviewActions(): bool
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return false;
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return false;
}
return ($this->currentReviewStateFor($tenant, $context->concernFamily)['current_concern_present'] ?? false) === true;
}
private function reviewModalDescription(string $targetManualState): \Closure
{
return function () use ($targetManualState): string {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return 'This triage session is no longer available.';
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return 'This triage session is no longer available.';
}
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
if (($reviewState['current_concern_present'] ?? false) !== true) {
return 'This triage session no longer points at a current concern.';
}
$currentLabel = BadgeRenderer::spec(
BadgeDomain::TenantTriageReviewState,
(string) ($reviewState['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED),
)->label;
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
return implode("\n\n", [
'Concern family: '.$this->concernFamilyLabel($context->concernFamily),
'Current review state: '.$currentLabel,
'Target state: '.$targetLabel,
'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.',
]);
};
}
private function handleReviewMutation(string $targetManualState, TenantTriageReviewService $service): void
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return;
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
Notification::make()
->title('No triage session available')
->warning()
->send();
return;
}
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
if (($reviewState['current_concern_present'] ?? false) !== true) {
Notification::make()
->title('No current concern to update')
->body('This arrival context no longer maps to an active concern.')
->warning()
->send();
return;
}
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
$actor = auth()->user();
$review = match ($targetManualState) {
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
tenant: $tenant,
concernFamily: $context->concernFamily,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor instanceof User ? $actor : null,
),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
tenant: $tenant,
concernFamily: $context->concernFamily,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor instanceof User ? $actor : null,
),
default => null,
};
if (! $review instanceof TenantTriageReview) {
return;
}
Notification::make()
->title('Review state updated')
->body(sprintf(
'%s is now %s for %s.',
$tenant->name,
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
$this->concernFamilyLabel($context->concernFamily),
))
->success()
->send();
}
/**
* @return array<string, mixed>|null
*/
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
{
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
return app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $tenant->workspace_id,
tenantIds: [(int) $tenant->getKey()],
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth],
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence],
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null;
}
private function concernFamilyLabel(string $concernFamily): string
{
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
default => 'Portfolio concern',
};
}
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
{
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState);
}
} }

View File

@ -46,6 +46,11 @@ class WorkspaceNeedsAttention extends Widget
*/ */
public array $emptyState = []; public array $emptyState = [];
/**
* @var array<int, array<string, mixed>>
*/
public array $triageReviewProgress = [];
/** /**
* @param array<int, array{ * @param array<int, array{
* key: string, * key: string,
@ -71,10 +76,12 @@ class WorkspaceNeedsAttention extends Widget
* action_label: string, * action_label: string,
* action_url: string * action_url: string
* } $emptyState * } $emptyState
* @param array<int, array<string, mixed>> $triageReviewProgress
*/ */
public function mount(array $items = [], array $emptyState = []): void public function mount(array $items = [], array $emptyState = [], array $triageReviewProgress = []): void
{ {
$this->items = $items; $this->items = $items;
$this->emptyState = $emptyState; $this->emptyState = $emptyState;
$this->triageReviewProgress = $triageReviewProgress;
} }
} }

View File

@ -5,7 +5,6 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -144,11 +143,6 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
default => ProviderConsentStatus::Required, default => ProviderConsentStatus::Required,
}; };
$verificationStatus = ProviderVerificationStatus::Unknown; $verificationStatus = ProviderVerificationStatus::Unknown;
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$reasonCode = match ($status) { $reasonCode = match ($status) {
'ok' => null, 'ok' => null,
'error' => ProviderReasonCodes::ProviderAuthFailed, 'error' => ProviderReasonCodes::ProviderAuthFailed,
@ -164,19 +158,18 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
[ [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => $consentStatus->value, 'consent_status' => $consentStatus->value,
'consent_granted_at' => $status === 'ok' ? now() : null, 'consent_granted_at' => $status === 'ok' ? now() : null,
'consent_last_checked_at' => now(), 'consent_last_checked_at' => now(),
'consent_error_code' => $reasonCode, 'consent_error_code' => $status === 'error' ? $reasonCode : null,
'consent_error_message' => $error, 'consent_error_message' => $status === 'error' ? $error : null,
'verification_status' => $verificationStatus->value, 'verification_status' => $verificationStatus->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => $reasonCode, 'last_error_reason_code' => $reasonCode,
'last_error_message' => $error, 'last_error_message' => $status === 'ok' ? null : $error,
'is_default' => $hasDefault ? false : true, 'is_default' => $hasDefault ? false : true,
], ],
); );

View File

@ -6,7 +6,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\AdminConsentUrlFactory; use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -100,12 +99,6 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
->where('is_default', true) ->where('is_default', true)
->exists(); ->exists();
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
$connection = ProviderConnection::query()->updateOrCreate( $connection = ProviderConnection::query()->updateOrCreate(
[ [
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -115,15 +108,14 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
[ [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,

View File

@ -385,8 +385,6 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
$connection->update([ $connection->update([
'consent_status' => $projected['consent_status'], 'consent_status' => $projected['consent_status'],
'verification_status' => $projected['verification_status'], 'verification_status' => $projected['verification_status'],
'status' => $projected['status'],
'health_status' => $projected['health_status'],
'last_health_check_at' => now(), 'last_health_check_at' => now(),
'last_error_reason_code' => $projected['last_error_reason_code'], 'last_error_reason_code' => $projected['last_error_reason_code'],
'last_error_message' => $projected['last_error_message'], 'last_error_message' => $projected['last_error_message'],
@ -449,12 +447,11 @@ private function logVerificationResult(
'metadata' => [ 'metadata' => [
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
'connection_type' => $connection->connection_type?->value ?? $connection->connection_type, 'connection_type' => $connection->connection_type?->value ?? $connection->connection_type,
'is_enabled' => (bool) $connection->is_enabled,
'consent_status' => $connection->consent_status?->value ?? $connection->consent_status, 'consent_status' => $connection->consent_status?->value ?? $connection->consent_status,
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status, 'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
'credential_source' => $identity->credentialSource, 'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId, 'effective_client_id' => $identity->effectiveClientId,
'status' => $connection->status,
'health_status' => $connection->health_status,
'reason_code' => $reasonCode, 'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(), 'operation_run_id' => (int) $run->getKey(),
'previous_consent_status' => $previousConsentStatus, 'previous_consent_status' => $previousConsentStatus,

View File

@ -13,6 +13,7 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderConsentStatus;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -44,7 +45,10 @@ public function handle(
// FR-018: Skip tenants without active provider connection // FR-018: Skip tenants without active provider connection
$hasConnection = ProviderConnection::query() $hasConnection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('status', 'connected') ->where('provider', 'microsoft')
->where('is_default', true)
->where('is_enabled', true)
->where('consent_status', ProviderConsentStatus::Granted->value)
->exists(); ->exists();
if (! $hasConnection) { if (! $hasConnection) {

View File

@ -4,6 +4,7 @@
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -37,4 +38,30 @@ public function assignedByUser(): BelongsTo
{ {
return $this->belongsTo(User::class, 'assigned_by_user_id'); return $this->belongsTo(User::class, 'assigned_by_user_id');
} }
public function scopeForBaselineProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query->where('baseline_profile_id', $profileId);
}
public function scopeInWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public static function assignedTenantIdsForProfile(BaselineProfile|int $profile, ?int $workspaceId = null): array
{
return static::query()
->when($workspaceId !== null, fn (Builder $query): Builder => $query->inWorkspace($workspaceId))
->forBaselineProfile($profile)
->pluck('tenant_id')
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->filter(static fn (int $tenantId): bool => $tenantId > 0)
->values()
->all();
}
} }

View File

@ -274,6 +274,18 @@ public function scopeOpenDrift(Builder $query): Builder
->openWorkflow(); ->openWorkflow();
} }
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->drift()
->where('source', 'baseline.compare')
->where('scope_key', 'baseline_profile:'.$profileId);
}
public function scopeOverdueOpen(Builder $query): Builder public function scopeOverdueOpen(Builder $query): Builder
{ {
return $query return $query

View File

@ -6,6 +6,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy; use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState; use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -89,6 +90,17 @@ public function scopeTerminalFailure(Builder $query): Builder
->where('outcome', OperationRunOutcome::Failed->value); ->where('outcome', OperationRunOutcome::Failed->value);
} }
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->where('type', OperationRunType::BaselineCompare->value)
->where('context->baseline_profile_id', $profileId);
}
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{ {
$policy ??= app(OperationLifecyclePolicy::class); $policy ??= app(OperationLifecyclePolicy::class);
@ -284,6 +296,34 @@ public function isGovernanceArtifactOperation(): bool
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
} }
/**
* @param array<int, int> $tenantIds
* @return \Illuminate\Support\Collection<int, self>
*/
public static function latestBaselineCompareRunsForProfile(
BaselineProfile|int $profile,
array $tenantIds,
?int $workspaceId = null,
bool $completedOnly = false,
): \Illuminate\Support\Collection {
if ($tenantIds === []) {
return collect();
}
$runs = static::query()
->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
->whereIn('tenant_id', $tenantIds)
->baselineCompareForProfile($profile)
->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value))
->orderByDesc('completed_at')
->orderByDesc('id')
->get();
return $runs
->unique(static fn (self $run): int => (int) $run->tenant_id)
->values();
}
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
{ {
if ($tenantId <= 0) { if ($tenantId <= 0) {

View File

@ -21,6 +21,7 @@ class ProviderConnection extends Model
protected $casts = [ protected $casts = [
'is_default' => 'boolean', 'is_default' => 'boolean',
'is_enabled' => 'boolean',
'connection_type' => ProviderConnectionType::class, 'connection_type' => ProviderConnectionType::class,
'consent_status' => ProviderConsentStatus::class, 'consent_status' => ProviderConsentStatus::class,
'consent_granted_at' => 'datetime', 'consent_granted_at' => 'datetime',
@ -151,7 +152,6 @@ public function requiresMigrationReview(): bool
*/ */
public function classificationProjection( public function classificationProjection(
\App\Services\Providers\ProviderConnectionClassificationResult $result, \App\Services\Providers\ProviderConnectionClassificationResult $result,
\App\Services\Providers\ProviderConnectionStateProjector $stateProjector,
): array { ): array {
$metadata = array_merge( $metadata = array_merge(
is_array($this->metadata) ? $this->metadata : [], is_array($this->metadata) ? $this->metadata : [],
@ -166,17 +166,8 @@ public function classificationProjection(
]; ];
if ($result->reviewRequired) { if ($result->reviewRequired) {
$statusProjection = $stateProjector->project(
connectionType: $result->suggestedConnectionType,
consentStatus: $this->consent_status,
verificationStatus: ProviderVerificationStatus::Blocked,
currentStatus: is_string($this->status) ? $this->status : null,
);
return $projection + [ return $projection + [
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => $statusProjection['status'],
'health_status' => $statusProjection['health_status'],
'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired, 'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired,
'last_error_message' => 'Legacy provider connection requires explicit migration review.', 'last_error_message' => 'Legacy provider connection requires explicit migration review.',
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
@ -197,19 +188,10 @@ public function classificationProjection(
$this->migration_review_required $this->migration_review_required
|| $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired || $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
) { ) {
$statusProjection = $stateProjector->project(
connectionType: $result->suggestedConnectionType,
consentStatus: $this->consent_status,
verificationStatus: $currentVerificationStatus,
currentStatus: is_string($this->status) ? $this->status : null,
);
return $projection + [ return $projection + [
'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus 'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus
? $currentVerificationStatus->value ? $currentVerificationStatus->value
: $currentVerificationStatus, : $currentVerificationStatus,
'status' => $statusProjection['status'],
'health_status' => $statusProjection['health_status'],
'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired 'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
? null ? null
: $currentReasonCode, : $currentReasonCode,

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantTriageReview extends Model
{
use HasFactory;
public const STATE_REVIEWED = 'reviewed';
public const STATE_FOLLOW_UP_NEEDED = 'follow_up_needed';
public const DERIVED_STATE_NOT_REVIEWED = 'not_reviewed';
public const DERIVED_STATE_CHANGED_SINCE_REVIEW = 'changed_since_review';
/**
* @var list<string>
*/
public const ACTIVE_CONCERN_FAMILIES = [
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
];
/**
* @var list<string>
*/
public const MANUAL_STATES = [
self::STATE_REVIEWED,
self::STATE_FOLLOW_UP_NEEDED,
];
/**
* @var list<string>
*/
public const DERIVED_STATES = [
self::DERIVED_STATE_NOT_REVIEWED,
self::STATE_REVIEWED,
self::STATE_FOLLOW_UP_NEEDED,
self::DERIVED_STATE_CHANGED_SINCE_REVIEW,
];
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'review_snapshot' => 'array',
'reviewed_at' => 'datetime',
'last_seen_matching_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForTenant(Builder $query, int $tenantId): Builder
{
return $query->where('tenant_id', $tenantId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForConcernFamily(Builder $query, string $concernFamily): Builder
{
return $query->where('concern_family', $concernFamily);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('resolved_at');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeResolved(Builder $query): Builder
{
return $query->whereNotNull('resolved_at');
}
}

View File

@ -79,8 +79,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->singleton(CapabilityResolver::class); $this->app->scoped(CapabilityResolver::class);
$this->app->singleton(WorkspaceCapabilityResolver::class); $this->app->scoped(WorkspaceCapabilityResolver::class);
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class); $this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);

View File

@ -32,6 +32,7 @@
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem; use Filament\Navigation\NavigationItem;
@ -62,6 +63,7 @@ public function panel(Panel $panel): Panel
->brandLogoHeight('2rem') ->brandLogoHeight('2rem')
->homeUrl(fn (): string => route('admin.home')) ->homeUrl(fn (): string => route('admin.home'))
->favicon(asset('favicon.ico')) ->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->authenticatedRoutes(function (Panel $panel): void { ->authenticatedRoutes(function (Panel $panel): void {
ChooseWorkspace::registerRoutes($panel); ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel); ChooseTenant::registerRoutes($panel);
@ -182,7 +184,7 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class, FilamentInfoWidget::class,
]) ])
->databaseNotifications() ->databaseNotifications()
->databaseNotificationsPolling('30s') ->databaseNotificationsPolling(null)
->unsavedChangesAlerts() ->unsavedChangesAlerts()
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,

View File

@ -7,6 +7,7 @@
use App\Http\Middleware\UseSystemSessionCookie; use App\Http\Middleware\UseSystemSessionCookie;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\PanelThemeAsset; use App\Support\Filament\PanelThemeAsset;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -31,11 +32,12 @@ public function panel(Panel $panel): Panel
->path('system') ->path('system')
->authGuard('platform') ->authGuard('platform')
->login(Login::class) ->login(Login::class)
->font(null, provider: LocalFontProvider::class, preload: [])
->colors([ ->colors([
'primary' => Color::Blue, 'primary' => Color::Blue,
]) ])
->databaseNotifications() ->databaseNotifications()
->databaseNotificationsPolling('30s') ->databaseNotificationsPolling(null)
->renderHook( ->renderHook(
PanelsRenderHook::BODY_START, PanelsRenderHook::BODY_START,
fn () => view('filament.system.components.break-glass-banner')->render(), fn () => view('filament.system.components.break-glass-banner')->render(),

View File

@ -10,6 +10,7 @@
use App\Support\Middleware\DenyNonMemberTenantAccess; use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -40,6 +41,7 @@ public function panel(Panel $panel): Panel
->brandLogo(fn () => view('filament.admin.logo')) ->brandLogo(fn () => view('filament.admin.logo'))
->brandLogoHeight('2rem') ->brandLogoHeight('2rem')
->favicon(asset('favicon.ico')) ->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->tenant(Tenant::class, slugAttribute: 'external_id') ->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix(null) ->tenantRoutePrefix(null)
->tenantMenu(fn (): bool => filled(Filament::getTenant())) ->tenantMenu(fn (): bool => filled(Filament::getTenant()))
@ -93,7 +95,7 @@ public function panel(Panel $panel): Panel
FilamentInfoWidget::class, FilamentInfoWidget::class,
]) ])
->databaseNotifications() ->databaseNotifications()
->databaseNotificationsPolling('30s') ->databaseNotificationsPolling(null)
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,

View File

@ -54,6 +54,7 @@ class RoleCapabilityMap
Capabilities::REVIEW_PACK_MANAGE, Capabilities::REVIEW_PACK_MANAGE,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_REVIEW_MANAGE, Capabilities::TENANT_REVIEW_MANAGE,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
Capabilities::EVIDENCE_VIEW, Capabilities::EVIDENCE_VIEW,
Capabilities::EVIDENCE_MANAGE, Capabilities::EVIDENCE_MANAGE,
], ],
@ -94,6 +95,7 @@ class RoleCapabilityMap
Capabilities::REVIEW_PACK_MANAGE, Capabilities::REVIEW_PACK_MANAGE,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_REVIEW_MANAGE, Capabilities::TENANT_REVIEW_MANAGE,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
Capabilities::EVIDENCE_VIEW, Capabilities::EVIDENCE_VIEW,
Capabilities::EVIDENCE_MANAGE, Capabilities::EVIDENCE_MANAGE,
], ],
@ -121,6 +123,7 @@ class RoleCapabilityMap
Capabilities::REVIEW_PACK_VIEW, Capabilities::REVIEW_PACK_VIEW,
Capabilities::TENANT_REVIEW_VIEW, Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
Capabilities::EVIDENCE_VIEW, Capabilities::EVIDENCE_VIEW,
], ],

View File

@ -11,6 +11,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
@ -28,6 +29,7 @@ public function __construct(
private readonly BaselineFullContentRolloutGate $rolloutGate, private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver, private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard, private readonly BaselineSupportCapabilityGuard $capabilityGuard,
private readonly CapabilityResolver $capabilityResolver,
) {} ) {}
/** /**
@ -47,12 +49,34 @@ public function startCompare(
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT); return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
} }
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id); $profile = $assignment->baselineProfile;
if (! $profile instanceof BaselineProfile) { if (! $profile instanceof BaselineProfile) {
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE); return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
} }
return $this->startCompareForProfile($profile, $tenant, $initiator, $baselineSnapshotId);
}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
*/
public function startCompareForProfile(
BaselineProfile $profile,
Tenant $tenant,
User $initiator,
?int $baselineSnapshotId = null,
): array {
$assignment = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('baseline_profile_id', (int) $profile->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
}
$precondition = $this->validatePreconditions($profile); $precondition = $this->validatePreconditions($profile);
if ($precondition !== null) { if ($precondition !== null) {
@ -124,6 +148,103 @@ public function startCompare(
return ['ok' => true, 'run' => $run]; return ['ok' => true, 'run' => $run];
} }
/**
* @return array{
* baselineProfileId: int,
* visibleAssignedTenantCount: int,
* queuedCount: int,
* alreadyQueuedCount: int,
* blockedCount: int,
* targets: list<array{tenantId: int, runId: ?int, launchState: string, reasonCode: ?string}>
* }
*/
public function startCompareForVisibleAssignments(BaselineProfile $profile, User $initiator): array
{
$assignments = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->with('tenant')
->get();
$queuedCount = 0;
$alreadyQueuedCount = 0;
$blockedCount = 0;
$targets = [];
foreach ($assignments as $assignment) {
$tenant = $assignment->tenant;
if (! $tenant instanceof Tenant) {
continue;
}
if (! $this->capabilityResolver->isMember($initiator, $tenant)) {
continue;
}
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_VIEW)) {
continue;
}
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_SYNC)) {
$blockedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => null,
'launchState' => 'blocked',
'reasonCode' => 'tenant_sync_required',
];
continue;
}
$result = $this->startCompareForProfile($profile, $tenant, $initiator);
$run = $result['run'] ?? null;
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : null;
if (! ($result['ok'] ?? false) || ! $run instanceof OperationRun) {
$blockedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => null,
'launchState' => 'blocked',
'reasonCode' => $reasonCode,
];
continue;
}
if ($run->wasRecentlyCreated) {
$queuedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => (int) $run->getKey(),
'launchState' => 'queued',
'reasonCode' => null,
];
continue;
}
$alreadyQueuedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => (int) $run->getKey(),
'launchState' => 'already_queued',
'reasonCode' => null,
];
}
return [
'baselineProfileId' => (int) $profile->getKey(),
'visibleAssignedTenantCount' => count($targets),
'queuedCount' => $queuedCount,
'alreadyQueuedCount' => $alreadyQueuedCount,
'blockedCount' => $blockedCount,
'targets' => $targets,
];
}
private function validatePreconditions(BaselineProfile $profile): ?string private function validatePreconditions(BaselineProfile $profile): ?string
{ {
if ($profile->status !== BaselineProfileStatus::Active) { if ($profile->status !== BaselineProfileStatus::Active) {

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Services\PortfolioTriage;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
final readonly class TenantTriageReviewService
{
public function __construct(
private TenantTriageReviewFingerprint $fingerprints,
private WorkspaceAuditLogger $auditLogger,
) {}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public function markReviewed(
Tenant $tenant,
string $concernFamily,
?TenantBackupHealthAssessment $backupHealth = null,
?array $recoveryEvidence = null,
?User $actor = null,
): TenantTriageReview {
return $this->store(
tenant: $tenant,
concernFamily: $concernFamily,
manualState: TenantTriageReview::STATE_REVIEWED,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor,
);
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public function markFollowUpNeeded(
Tenant $tenant,
string $concernFamily,
?TenantBackupHealthAssessment $backupHealth = null,
?array $recoveryEvidence = null,
?User $actor = null,
): TenantTriageReview {
return $this->store(
tenant: $tenant,
concernFamily: $concernFamily,
manualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor,
);
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
private function store(
Tenant $tenant,
string $concernFamily,
string $manualState,
?TenantBackupHealthAssessment $backupHealth,
?array $recoveryEvidence,
?User $actor,
): TenantTriageReview {
if (! in_array($manualState, TenantTriageReview::MANUAL_STATES, true)) {
throw new InvalidArgumentException('Unsupported triage review state.');
}
if (! is_numeric($tenant->workspace_id) || (int) $tenant->workspace_id <= 0) {
throw new InvalidArgumentException('Tenant must belong to a workspace.');
}
$currentConcern = $this->fingerprints->forConcernFamily($concernFamily, $backupHealth, $recoveryEvidence);
if ($currentConcern === null) {
throw new InvalidArgumentException('No current triage concern is available for review.');
}
$workspaceId = (int) $tenant->workspace_id;
$now = now();
/** @var TenantTriageReview $review */
$review = DB::transaction(function () use (
$tenant,
$workspaceId,
$manualState,
$currentConcern,
$actor,
$now,
): TenantTriageReview {
TenantTriageReview::query()
->forWorkspace($workspaceId)
->forTenant((int) $tenant->getKey())
->where('concern_family', $currentConcern['concern_family'])
->active()
->update([
'resolved_at' => $now,
'updated_at' => $now,
]);
return TenantTriageReview::query()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'concern_family' => $currentConcern['concern_family'],
'current_state' => $manualState,
'reviewed_at' => $now,
'reviewed_by_user_id' => $actor?->getKey(),
'review_fingerprint' => $currentConcern['fingerprint'],
'review_snapshot' => $currentConcern['snapshot'],
'last_seen_matching_at' => $now,
'resolved_at' => null,
]);
});
$review->loadMissing('reviewer');
$this->auditLogger->log(
workspace: $tenant->workspace,
action: $manualState === TenantTriageReview::STATE_REVIEWED
? AuditActionId::TenantTriageReviewMarkedReviewed
: AuditActionId::TenantTriageReviewMarkedFollowUpNeeded,
context: [
'metadata' => [
'concern_family' => $currentConcern['concern_family'],
'concern_state' => $currentConcern['concern_state'],
'reason_code' => $currentConcern['snapshot']['reasonCode'] ?? null,
'review_state' => $manualState,
],
],
actor: $actor,
resourceType: 'tenant_triage_review',
resourceId: (string) $review->getKey(),
targetLabel: $tenant->name,
tenant: $tenant,
summary: $this->summaryFor($currentConcern['concern_family'], $manualState),
);
return $review;
}
private function summaryFor(string $concernFamily, string $manualState): string
{
$family = match ($concernFamily) {
'backup_health' => 'Backup health',
'recovery_evidence' => 'Recovery evidence',
default => 'Portfolio concern',
};
$state = $manualState === TenantTriageReview::STATE_REVIEWED
? 'reviewed'
: 'follow-up needed';
return sprintf('%s marked %s', $family, $state);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Services\Providers\Contracts; namespace App\Services\Providers\Contracts;
use App\Support\Providers\ProviderVerificationStatus;
final class HealthResult final class HealthResult
{ {
/** /**
@ -9,8 +11,7 @@ final class HealthResult
*/ */
public function __construct( public function __construct(
public readonly bool $healthy, public readonly bool $healthy,
public readonly string $status, public readonly string $verificationStatus,
public readonly string $healthStatus,
public readonly ?string $reasonCode = null, public readonly ?string $reasonCode = null,
public readonly ?string $message = null, public readonly ?string $message = null,
public readonly array $meta = [], public readonly array $meta = [],
@ -19,9 +20,9 @@ public function __construct(
/** /**
* @param array<string, mixed> $meta * @param array<string, mixed> $meta
*/ */
public static function ok(string $status = 'connected', string $healthStatus = 'ok', array $meta = []): self public static function ok(array $meta = []): self
{ {
return new self(true, $status, $healthStatus, null, null, $meta); return new self(true, ProviderVerificationStatus::Healthy->value, null, null, $meta);
} }
/** /**
@ -30,10 +31,9 @@ public static function ok(string $status = 'connected', string $healthStatus = '
public static function failed( public static function failed(
string $reasonCode, string $reasonCode,
string $message, string $message,
string $status = 'error', string $verificationStatus = 'error',
string $healthStatus = 'down',
array $meta = [], array $meta = [],
): self { ): self {
return new self(false, $status, $healthStatus, $reasonCode, $message, $meta); return new self(false, $verificationStatus, $reasonCode, $message, $meta);
} }
} }

View File

@ -8,6 +8,7 @@
use App\Services\Providers\Contracts\ProviderHealthCheck; use App\Services\Providers\Contracts\ProviderHealthCheck;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Throwable; use Throwable;
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
@ -25,15 +26,12 @@ public function check(ProviderConnection $connection): HealthResult
return HealthResult::failed( return HealthResult::failed(
reasonCode: $reasonCode, reasonCode: $reasonCode,
message: $message !== '' ? $message : 'Health check failed.', message: $message !== '' ? $message : 'Health check failed.',
status: $this->statusForReason($reasonCode), verificationStatus: $this->verificationStatusForReason($reasonCode),
healthStatus: $this->healthForReason($reasonCode),
); );
} }
if ($response->successful()) { if ($response->successful()) {
return HealthResult::ok( return HealthResult::ok(
status: 'connected',
healthStatus: 'ok',
meta: [ meta: [
'organization_id' => $response->data['id'] ?? null, 'organization_id' => $response->data['id'] ?? null,
'organization_display_name' => $response->data['displayName'] ?? null, 'organization_display_name' => $response->data['displayName'] ?? null,
@ -47,8 +45,7 @@ public function check(ProviderConnection $connection): HealthResult
return HealthResult::failed( return HealthResult::failed(
reasonCode: $reasonCode, reasonCode: $reasonCode,
message: $message !== '' ? $message : 'Health check failed.', message: $message !== '' ? $message : 'Health check failed.',
status: $this->statusForReason($reasonCode), verificationStatus: $this->verificationStatusForReason($reasonCode),
healthStatus: $this->healthForReason($reasonCode),
meta: [ meta: [
'http_status' => $response->status, 'http_status' => $response->status,
], ],
@ -89,24 +86,14 @@ private function messageForResponse(GraphResponse $response): string
return 'Health check failed.'; return 'Health check failed.';
} }
private function statusForReason(string $reasonCode): string private function verificationStatusForReason(string $reasonCode): string
{ {
return match ($reasonCode) { return match ($reasonCode) {
ProviderReasonCodes::ProviderAuthFailed, ProviderReasonCodes::RateLimited => ProviderVerificationStatus::Degraded->value,
ProviderReasonCodes::ProviderPermissionDenied, ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentMissing => 'needs_consent', ProviderReasonCodes::ProviderConsentFailed,
default => 'error', ProviderReasonCodes::ProviderConsentRevoked => ProviderVerificationStatus::Blocked->value,
}; default => ProviderVerificationStatus::Error->value,
}
private function healthForReason(string $reasonCode): string
{
return match ($reasonCode) {
ProviderReasonCodes::RateLimited => 'degraded',
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderPermissionDenied => 'down',
default => 'down',
}; };
} }
} }

View File

@ -50,9 +50,9 @@ public function classify(
'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null, 'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null,
'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false), 'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false),
'current_connection_type' => $currentConnectionType, 'current_connection_type' => $currentConnectionType,
'is_enabled' => (bool) $connection->is_enabled,
'consent_status' => $this->enumValue($connection->consent_status), 'consent_status' => $this->enumValue($connection->consent_status),
'verification_status' => $this->enumValue($connection->verification_status), 'verification_status' => $this->enumValue($connection->verification_status),
'status' => is_string($connection->status) ? $connection->status : null,
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null, 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
], ],
effectiveApp: $this->effectiveAppMetadata( effectiveApp: $this->effectiveAppMetadata(

View File

@ -15,10 +15,7 @@
final class ProviderConnectionMutationService final class ProviderConnectionMutationService
{ {
public function __construct( public function __construct(private readonly CredentialManager $credentials) {}
private readonly CredentialManager $credentials,
private readonly ProviderConnectionStateProjector $stateProjector,
) {}
public function enableDedicatedOverride( public function enableDedicatedOverride(
ProviderConnection $connection, ProviderConnection $connection,
@ -50,15 +47,10 @@ public function enableDedicatedOverride(
: $this->normalizeConsentStatus($connection->consent_status); : $this->normalizeConsentStatus($connection->consent_status);
$verificationStatus = ProviderVerificationStatus::Unknown; $verificationStatus = ProviderVerificationStatus::Unknown;
$updates = $this->projectConnectionState( $connection->forceFill([
connection: $connection,
connectionType: ProviderConnectionType::Dedicated,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$connection->forceFill(array_merge($updates, [
'connection_type' => ProviderConnectionType::Dedicated->value, 'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => $needsConsentReset 'last_error_reason_code' => $needsConsentReset
? ProviderReasonCodes::ProviderConsentMissing ? ProviderReasonCodes::ProviderConsentMissing
@ -67,9 +59,9 @@ public function enableDedicatedOverride(
'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []), 'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []),
'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at, 'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at,
'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at, 'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at,
'consent_error_code' => $needsConsentReset ? null : $connection->consent_error_code, 'consent_error_code' => null,
'consent_error_message' => $needsConsentReset ? null : $connection->consent_error_message, 'consent_error_message' => null,
]))->save(); ])->save();
$this->credentials->upsertClientSecretCredential( $this->credentials->upsertClientSecretCredential(
connection: $connection->fresh(), connection: $connection->fresh(),
@ -90,15 +82,9 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec
$connection->credential->delete(); $connection->credential->delete();
} }
$updates = $this->projectConnectionState( $connection->forceFill([
connection: $connection,
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
$connection->forceFill(array_merge($updates, [
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
@ -108,7 +94,7 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'last_error_message' => null, 'last_error_message' => null,
'scopes_granted' => [], 'scopes_granted' => [],
]))->save(); ])->save();
return $connection->fresh(['credential']); return $connection->fresh(['credential']);
}); });
@ -126,47 +112,19 @@ public function deleteDedicatedCredential(ProviderConnection $connection): Provi
$consentStatus = $this->normalizeConsentStatus($connection->consent_status); $consentStatus = $this->normalizeConsentStatus($connection->consent_status);
$verificationStatus = ProviderVerificationStatus::Blocked; $verificationStatus = ProviderVerificationStatus::Blocked;
$updates = $this->projectConnectionState( $connection->forceFill([
connection: $connection,
connectionType: ProviderConnectionType::Dedicated,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$connection->forceFill(array_merge($updates, [
'connection_type' => ProviderConnectionType::Dedicated->value, 'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => $consentStatus->value, 'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value, 'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing, 'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing,
'last_error_message' => 'Dedicated credential is missing.', 'last_error_message' => 'Dedicated credential is missing.',
]))->save(); ])->save();
return $connection->fresh(['credential']); return $connection->fresh(['credential']);
}); });
} }
private function projectConnectionState(
ProviderConnection $connection,
ProviderConnectionType $connectionType,
ProviderConsentStatus $consentStatus,
ProviderVerificationStatus $verificationStatus,
): array {
$projected = $this->stateProjector->project(
connectionType: $connectionType,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
currentStatus: is_string($connection->status) ? $connection->status : null,
);
return [
'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value,
'status' => $projected['status'],
'health_status' => $projected['health_status'],
];
}
private function normalizeConsentStatus( private function normalizeConsentStatus(
ProviderConsentStatus|string|null $consentStatus, ProviderConsentStatus|string|null $consentStatus,
): ProviderConsentStatus { ): ProviderConsentStatus {

View File

@ -54,7 +54,7 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
); );
} }
if ((string) $connection->status === 'disabled') { if (! (bool) $connection->is_enabled) {
return ProviderConnectionResolution::blocked( return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid, ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is disabled.', 'Provider connection is disabled.',
@ -95,39 +95,30 @@ private function consentBlocker(ProviderConnection $connection): ?ProviderConnec
{ {
$consentStatus = $connection->consent_status; $consentStatus = $connection->consent_status;
if ($consentStatus instanceof ProviderConsentStatus) { if (! $consentStatus instanceof ProviderConsentStatus && is_string($consentStatus)) {
return match ($consentStatus) { $consentStatus = ProviderConsentStatus::tryFrom(trim($consentStatus));
ProviderConsentStatus::Required => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentMissing,
'Provider connection requires admin consent before use.',
'ext.connection_needs_consent',
$connection,
),
ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentFailed,
'Provider connection consent failed. Retry admin consent before use.',
'ext.connection_consent_failed',
$connection,
),
ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentRevoked,
'Provider connection consent was revoked. Grant admin consent again before use.',
'ext.connection_consent_revoked',
$connection,
),
default => null,
};
} }
if ((string) $connection->status === 'needs_consent') { return match ($consentStatus) {
return ProviderConnectionResolution::blocked( ProviderConsentStatus::Required => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentMissing, ProviderReasonCodes::ProviderConsentMissing,
'Provider connection requires admin consent before use.', 'Provider connection requires admin consent before use.',
'ext.connection_needs_consent', 'ext.connection_needs_consent',
$connection, $connection,
); ),
} ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentFailed,
return null; 'Provider connection consent failed. Retry admin consent before use.',
'ext.connection_consent_failed',
$connection,
),
ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentRevoked,
'Provider connection consent was revoked. Grant admin consent again before use.',
'ext.connection_consent_revoked',
$connection,
),
default => null,
};
} }
} }

View File

@ -4,55 +4,16 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Services\Providers\Contracts\HealthResult; use App\Services\Providers\Contracts\HealthResult;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
final class ProviderConnectionStateProjector final class ProviderConnectionStateProjector
{ {
/**
* @return array{status: string, health_status: string}
*/
public function projectForConnection(ProviderConnection $connection): array
{
return $this->project(
connectionType: $connection->connection_type,
consentStatus: $connection->consent_status,
verificationStatus: $connection->verification_status,
currentStatus: is_string($connection->status) ? $connection->status : null,
);
}
/**
* @return array{status: string, health_status: string}
*/
public function project(
ProviderConnectionType|string|null $connectionType,
ProviderConsentStatus|string|null $consentStatus,
ProviderVerificationStatus|string|null $verificationStatus,
?string $currentStatus = null,
): array {
$resolvedConnectionType = $this->normalizeConnectionType($connectionType) ?? ProviderConnectionType::Platform;
$resolvedConsentStatus = $this->normalizeConsentStatus($consentStatus) ?? ProviderConsentStatus::Unknown;
$resolvedVerificationStatus = $this->normalizeVerificationStatus($verificationStatus) ?? ProviderVerificationStatus::Unknown;
$status = $currentStatus === 'disabled'
? 'disabled'
: $this->projectStatus($resolvedConnectionType, $resolvedConsentStatus, $resolvedVerificationStatus);
return [
'status' => $status,
'health_status' => $this->projectHealthStatus($resolvedVerificationStatus),
];
}
/** /**
* @return array{ * @return array{
* consent_status: ProviderConsentStatus, * consent_status: ProviderConsentStatus,
* verification_status: ProviderVerificationStatus, * verification_status: ProviderVerificationStatus,
* status: string,
* health_status: string,
* last_error_reason_code: ?string, * last_error_reason_code: ?string,
* last_error_message: ?string, * last_error_message: ?string,
* consent_error_code: ?string, * consent_error_code: ?string,
@ -62,22 +23,14 @@ public function project(
*/ */
public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array
{ {
$currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) $currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) ?? ProviderConsentStatus::Unknown;
?? (((string) $connection->status === 'needs_consent') ? ProviderConsentStatus::Required : ProviderConsentStatus::Unknown);
$effectiveReasonCode = $result->healthy $effectiveReasonCode = $result->healthy
? null ? null
: $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode); : $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode);
$consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy); $consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy);
$verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result->healthy, $result->healthStatus); $verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result);
$projected = $this->project(
connectionType: $connection->connection_type,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
currentStatus: is_string($connection->status) ? $connection->status : null,
);
$consentErrorCode = in_array($consentStatus, [ $consentErrorCode = in_array($consentStatus, [
ProviderConsentStatus::Required, ProviderConsentStatus::Required,
@ -88,8 +41,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt
return [ return [
'consent_status' => $consentStatus, 'consent_status' => $consentStatus,
'verification_status' => $verificationStatus, 'verification_status' => $verificationStatus,
'status' => $projected['status'],
'health_status' => $projected['health_status'],
'last_error_reason_code' => $effectiveReasonCode, 'last_error_reason_code' => $effectiveReasonCode,
'last_error_message' => $result->healthy ? null : $result->message, 'last_error_message' => $result->healthy ? null : $result->message,
'consent_error_code' => $consentErrorCode, 'consent_error_code' => $consentErrorCode,
@ -99,19 +50,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt
]; ];
} }
private function normalizeConnectionType(ProviderConnectionType|string|null $connectionType): ?ProviderConnectionType
{
if ($connectionType instanceof ProviderConnectionType) {
return $connectionType;
}
if (! is_string($connectionType)) {
return null;
}
return ProviderConnectionType::tryFrom(trim($connectionType));
}
private function normalizeConsentStatus(ProviderConsentStatus|string|null $consentStatus): ?ProviderConsentStatus private function normalizeConsentStatus(ProviderConsentStatus|string|null $consentStatus): ?ProviderConsentStatus
{ {
if ($consentStatus instanceof ProviderConsentStatus) { if ($consentStatus instanceof ProviderConsentStatus) {
@ -139,41 +77,6 @@ private function normalizeVerificationStatus(
return ProviderVerificationStatus::tryFrom(trim($verificationStatus)); return ProviderVerificationStatus::tryFrom(trim($verificationStatus));
} }
private function projectStatus(
ProviderConnectionType $connectionType,
ProviderConsentStatus $consentStatus,
ProviderVerificationStatus $verificationStatus,
): string {
if ($connectionType === ProviderConnectionType::Dedicated && $verificationStatus === ProviderVerificationStatus::Blocked) {
return 'error';
}
if ($consentStatus === ProviderConsentStatus::Failed) {
return 'error';
}
if ($consentStatus !== ProviderConsentStatus::Granted) {
return 'needs_consent';
}
return match ($verificationStatus) {
ProviderVerificationStatus::Blocked,
ProviderVerificationStatus::Error => 'error',
default => 'connected',
};
}
private function projectHealthStatus(ProviderVerificationStatus $verificationStatus): string
{
return match ($verificationStatus) {
ProviderVerificationStatus::Healthy => 'ok',
ProviderVerificationStatus::Degraded => 'degraded',
ProviderVerificationStatus::Blocked,
ProviderVerificationStatus::Error => 'down',
default => 'unknown',
};
}
private function effectiveReasonCodeForVerification( private function effectiveReasonCodeForVerification(
ProviderConsentStatus $currentConsentStatus, ProviderConsentStatus $currentConsentStatus,
?string $reasonCode, ?string $reasonCode,
@ -211,17 +114,12 @@ private function consentStatusAfterVerification(
private function verificationStatusAfterVerification( private function verificationStatusAfterVerification(
?string $reasonCode, ?string $reasonCode,
bool $healthy, HealthResult $result,
string $healthStatus,
): ProviderVerificationStatus { ): ProviderVerificationStatus {
if ($healthy) { if ($result->healthy) {
return ProviderVerificationStatus::Healthy; return ProviderVerificationStatus::Healthy;
} }
if ($healthStatus === 'degraded' || $reasonCode === ProviderReasonCodes::RateLimited) {
return ProviderVerificationStatus::Degraded;
}
if (in_array($reasonCode, [ if (in_array($reasonCode, [
ProviderReasonCodes::ProviderConsentMissing, ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed, ProviderReasonCodes::ProviderConsentFailed,
@ -238,6 +136,6 @@ private function verificationStatusAfterVerification(
return ProviderVerificationStatus::Blocked; return ProviderVerificationStatus::Blocked;
} }
return ProviderVerificationStatus::Error; return $this->normalizeVerificationStatus($result->verificationStatus) ?? ProviderVerificationStatus::Error;
} }
} }

View File

@ -10,7 +10,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Services\Providers\ProviderIdentityResolver; use App\Services\Providers\ProviderIdentityResolver;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
@ -27,7 +26,6 @@ public function __construct(
private readonly ProviderOperationStartGate $providers, private readonly ProviderOperationStartGate $providers,
private readonly ProviderConnectionResolver $connections, private readonly ProviderConnectionResolver $connections,
private readonly ProviderIdentityResolver $identityResolver, private readonly ProviderIdentityResolver $identityResolver,
private readonly ProviderConnectionStateProjector $stateProjector,
) {} ) {}
/** /**
@ -126,17 +124,8 @@ public function providerConnectionCheckUsingConnection(
); );
if ($result->status === 'started') { if ($result->status === 'started') {
$projectedState = $this->stateProjector->project(
connectionType: $connection->connection_type,
consentStatus: $connection->consent_status,
verificationStatus: ProviderVerificationStatus::Pending,
currentStatus: is_string($connection->status) ? $connection->status : null,
);
$connection->update([ $connection->update([
'verification_status' => ProviderVerificationStatus::Pending, 'verification_status' => ProviderVerificationStatus::Pending,
'status' => $projectedState['status'],
'health_status' => $projectedState['health_status'],
'last_error_reason_code' => null, 'last_error_reason_code' => null,
'last_error_message' => null, 'last_error_message' => null,
]); ]);

View File

@ -95,6 +95,8 @@ enum AuditActionId: string
case TenantReviewArchived = 'tenant_review.archived'; case TenantReviewArchived = 'tenant_review.archived';
case TenantReviewExported = 'tenant_review.exported'; case TenantReviewExported = 'tenant_review.exported';
case TenantReviewSuccessorCreated = 'tenant_review.successor_created'; case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
// Workspace selection / switch events (Spec 107). // Workspace selection / switch events (Spec 107).
case WorkspaceAutoSelected = 'workspace.auto_selected'; case WorkspaceAutoSelected = 'workspace.auto_selected';
@ -228,6 +230,8 @@ private static function labels(): array
self::TenantReviewArchived->value => 'Tenant review archived', self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
'baseline.capture.started' => 'Baseline capture started', 'baseline.capture.started' => 'Baseline capture started',
'baseline.capture.completed' => 'Baseline capture completed', 'baseline.capture.completed' => 'Baseline capture completed',
'baseline.capture.failed' => 'Baseline capture failed', 'baseline.capture.failed' => 'Baseline capture failed',

View File

@ -143,6 +143,9 @@ class Capabilities
public const TENANT_REVIEW_MANAGE = 'tenant_review.manage'; public const TENANT_REVIEW_MANAGE = 'tenant_review.manage';
// Portfolio triage review progress
public const TENANT_TRIAGE_REVIEW_MANAGE = 'tenant_triage_review.manage';
// Evidence snapshots // Evidence snapshots
public const EVIDENCE_VIEW = 'evidence.view'; public const EVIDENCE_VIEW = 'evidence.view';

View File

@ -49,8 +49,6 @@ final class BadgeCatalog
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class, BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class, BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class, BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class, BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class, BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
@ -64,9 +62,13 @@ final class BadgeCatalog
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class, BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
BadgeDomain::TenantTriageReviewState->value => Domains\TenantTriageReviewStateBadge::class,
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class, BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class, BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class, BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class,
BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class,
BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class,
]; ];
/** /**
@ -157,18 +159,6 @@ public static function normalizeState(mixed $value): ?string
return $normalized === '' ? null : $normalized; return $normalized === '' ? null : $normalized;
} }
public static function normalizeProviderConnectionStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'granted', 'connected' => 'connected',
'consent_required', 'required', 'needs_admin_consent', 'needs_consent', 'unknown' => 'needs_consent',
'failed', 'revoked', 'blocked' => 'error',
default => $state,
};
}
public static function normalizeProviderConsentStatus(mixed $value): ?string public static function normalizeProviderConsentStatus(mixed $value): ?string
{ {
$state = self::normalizeState($value); $state = self::normalizeState($value);
@ -195,17 +185,6 @@ public static function normalizeProviderVerificationStatus(mixed $value): ?strin
}; };
} }
public static function normalizeProviderConnectionHealth(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'healthy' => 'ok',
'blocked', 'error' => 'down',
default => $state,
};
}
public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string
{ {
$state = self::normalizeState($value); $state = self::normalizeState($value);

View File

@ -40,8 +40,6 @@ enum BadgeDomain: string
case RestoreResultStatus = 'restore_result_status'; case RestoreResultStatus = 'restore_result_status';
case ProviderConsentStatus = 'provider_connection.consent_status'; case ProviderConsentStatus = 'provider_connection.consent_status';
case ProviderVerificationStatus = 'provider_connection.verification_status'; case ProviderVerificationStatus = 'provider_connection.verification_status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status'; case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
case VerificationCheckStatus = 'verification_check_status'; case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity'; case VerificationCheckSeverity = 'verification_check_severity';
@ -55,7 +53,11 @@ enum BadgeDomain: string
case EvidenceCompleteness = 'evidence_completeness'; case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status'; case TenantReviewStatus = 'tenant_review_status';
case TenantReviewCompleteness = 'tenant_review_completeness'; case TenantReviewCompleteness = 'tenant_review_completeness';
case TenantTriageReviewState = 'tenant_triage_review_state';
case SystemHealth = 'system_health'; case SystemHealth = 'system_health';
case ReferenceResolutionState = 'reference_resolution_state'; case ReferenceResolutionState = 'reference_resolution_state';
case DiffRowStatus = 'diff_row_status'; case DiffRowStatus = 'diff_row_status';
case BaselineCompareMatrixState = 'baseline_compare_matrix_state';
case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness';
case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust';
} }

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixFreshnessBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
'fresh' => new BadgeSpec('Current result', 'success', 'heroicon-m-check-badge'),
'stale' => new BadgeSpec('Refresh recommended', 'warning', 'heroicon-m-arrow-path'),
'never_compared' => new BadgeSpec('Not compared yet', 'gray', 'heroicon-m-minus-circle'),
'unknown' => new BadgeSpec('Freshness unknown', 'info', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
'match' => new BadgeSpec('Reference aligned', 'success', 'heroicon-m-check-circle'),
'differ' => new BadgeSpec('Drift detected', 'danger', 'heroicon-m-exclamation-triangle'),
'missing' => new BadgeSpec('Missing from tenant', 'warning', 'heroicon-m-minus-circle'),
'ambiguous' => new BadgeSpec('Identity ambiguous', 'info', 'heroicon-m-question-mark-circle'),
'not_compared' => new BadgeSpec('Not compared', 'gray', 'heroicon-m-clock'),
'stale_result' => new BadgeSpec('Result stale', 'warning', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixTrustBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $value);
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConnectionHealthBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderConnectionHealth($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
'down' => new BadgeSpec('Down', 'danger', 'heroicon-m-x-circle'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConnectionStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderConnectionStatus($value);
return match ($state) {
'connected' => new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'),
'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\TenantTriageReview;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantTriageReviewStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
TenantTriageReview::DERIVED_STATE_NOT_REVIEWED => new BadgeSpec('Not reviewed', 'gray', 'heroicon-m-eye-slash'),
TenantTriageReview::STATE_REVIEWED => new BadgeSpec('Reviewed', 'success', 'heroicon-m-check-circle'),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => new BadgeSpec('Follow-up needed', 'danger', 'heroicon-m-exclamation-triangle'),
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => new BadgeSpec('Changed since review', 'warning', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -245,6 +245,57 @@ public static function topReasons(array $byReason, int $limit = 5): array
); );
} }
/**
* @return array<string, list<string>>
*/
public static function subjectReasonsFromOperationRun(?OperationRun $run): array
{
$details = self::fromOperationRun($run);
$buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : [];
$reasonMap = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$reasonCode = self::stringOrNull($bucket['reason_code'] ?? null);
if ($reasonCode === null) {
continue;
}
$rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
if ($policyType === null || $subjectKey === null) {
continue;
}
$compositeKey = self::subjectCompositeKey($policyType, $subjectKey);
$reasonMap[$compositeKey] ??= [];
$reasonMap[$compositeKey][] = $reasonCode;
}
}
return array_map(
static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))),
$reasonMap,
);
}
public static function subjectCompositeKey(string $policyType, string $subjectKey): string
{
return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey));
}
/** /**
* @param list<array<string, mixed>> $buckets * @param list<array<string, mixed>> $buckets
* @return list<array<string, mixed>> * @return list<array<string, mixed>>

View File

@ -4,6 +4,7 @@
namespace App\Support\Baselines; namespace App\Support\Baselines;
use App\Models\OperationRun;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor; use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\ExplanationFamily; use App\Support\Ui\OperatorExplanation\ExplanationFamily;
@ -18,6 +19,36 @@ public function __construct(
private readonly ReasonPresenter $reasonPresenter, private readonly ReasonPresenter $reasonPresenter,
) {} ) {}
public function trustLevelForRun(?OperationRun $run): string
{
if (! $run instanceof OperationRun) {
return TrustworthinessLevel::Unusable->value;
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [];
$coverage = is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : [];
$evidenceGaps = is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : [];
$reasonCode = is_string($baselineCompare['reason_code'] ?? null) ? trim((string) $baselineCompare['reason_code']) : null;
$proof = is_bool($coverage['proof'] ?? null) ? (bool) $coverage['proof'] : null;
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) ? $coverage['uncovered_types'] : [];
$evidenceGapCount = is_numeric($evidenceGaps['count'] ?? null) ? (int) $evidenceGaps['count'] : 0;
if ($run->status !== 'completed' || $run->outcome === 'failed') {
return TrustworthinessLevel::Unusable->value;
}
if ($proof === false || $reasonCode !== null) {
return TrustworthinessLevel::DiagnosticOnly->value;
}
if ($uncoveredTypes !== [] || $evidenceGapCount > 0) {
return TrustworthinessLevel::LimitedConfidence->value;
}
return TrustworthinessLevel::Trustworthy->value;
}
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
{ {
$reason = $stats->reasonCode !== null $reason = $stats->reasonCode !== null

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,28 @@ final class BaselineCompareSummaryAssessor
{ {
private const int STALE_AFTER_DAYS = 7; private const int STALE_AFTER_DAYS = 7;
public static function staleAfterDays(): int
{
return self::STALE_AFTER_DAYS;
}
public static function isStaleComparedAt(\DateTimeInterface|string|null $value): bool
{
if ($value === null) {
return false;
}
try {
$comparedAt = $value instanceof \DateTimeInterface
? CarbonImmutable::instance(\DateTimeImmutable::createFromInterface($value))
: CarbonImmutable::parse($value);
} catch (\Throwable) {
return false;
}
return $comparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
}
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
{ {
$explanation = $stats->operatorExplanation(); $explanation = $stats->operatorExplanation();
@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
return false; return false;
} }
try { return self::isStaleComparedAt($stats->lastComparedIso);
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
} catch (\Throwable) {
return false;
}
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
} }
} }

View File

@ -4,6 +4,9 @@
namespace App\Support\Navigation; namespace App\Support\Navigation;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final readonly class CanonicalNavigationContext final readonly class CanonicalNavigationContext
@ -63,4 +66,31 @@ public function toQuery(): array
return $query; return $query;
} }
/**
* @param array<string, mixed> $filters
*/
public static function forBaselineCompareMatrix(
BaselineProfile $profile,
array $filters = [],
?Tenant $tenant = null,
?string $subjectKey = null,
): self {
$parameters = array_filter([
'record' => $profile,
...$filters,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
return new self(
sourceSurface: 'baseline_compare_matrix',
canonicalRouteName: BaselineCompareMatrix::getRouteName(),
tenantId: $tenant?->getKey(),
backLinkLabel: 'Back to compare matrix',
backLinkUrl: BaselineCompareMatrix::getUrl($parameters, panel: 'admin'),
filterPayload: array_filter([
'baseline_profile_id' => (int) $profile->getKey(),
'subject_key' => $subjectKey,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
} }

View File

@ -48,7 +48,7 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
$request->query(PortfolioArrivalContextToken::QUERY_PARAMETER), $request->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
); );
if ($state === null || ! $this->matchesScope($tenant, $request, $state)) { if ($state === null || ! $this->matchesRequestScope($tenant, $request, $state)) {
$request->attributes->set(self::REQUEST_CACHE_KEY, null); $request->attributes->set(self::REQUEST_CACHE_KEY, null);
return null; return null;
@ -61,6 +61,26 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
return $context; return $context;
} }
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* }|null $state
*/
public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalContext
{
if ($state === null || ! $this->matchesTenantScope($tenant, $state)) {
return null;
}
return $this->buildContext($tenant, $state);
}
/** /**
* @param array{ * @param array{
* sourceSurface: string, * sourceSurface: string,
@ -72,7 +92,30 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
* returnFilters: array<string, mixed>|null * returnFilters: array<string, mixed>|null
* } $state * } $state
*/ */
private function matchesScope(Tenant $tenant, Request $request, array $state): bool private function matchesRequestScope(Tenant $tenant, Request $request, array $state): bool
{
if (! $this->matchesTenantScope($tenant, $state)) {
return false;
}
$workspaceId = $state['workspaceId'];
return $workspaceId === null
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
}
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* } $state
*/
private function matchesTenantScope(Tenant $tenant, array $state): bool
{ {
$tenantRouteKey = $state['tenantRouteKey']; $tenantRouteKey = $state['tenantRouteKey'];
@ -92,7 +135,7 @@ private function matchesScope(Tenant $tenant, Request $request, array $state): b
$workspaceId = $state['workspaceId']; $workspaceId = $state['workspaceId'];
return $workspaceId === null return $workspaceId === null
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId; || (int) $tenant->workspace_id === $workspaceId;
} }
/** /**

View File

@ -55,6 +55,7 @@ final class PortfolioArrivalContextToken
private const array RETURN_FILTER_ALLOWLIST = [ private const array RETURN_FILTER_ALLOWLIST = [
'backup_posture' => true, 'backup_posture' => true,
'recovery_evidence' => true, 'recovery_evidence' => true,
'review_state' => true,
'triage_sort' => true, 'triage_sort' => true,
]; ];

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use JsonException;
final class TenantTriageReviewFingerprint
{
/**
* @param array<string, mixed>|null $recoveryEvidence
* @return array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* }
* }|null
*/
public function forConcernFamily(
string $concernFamily,
?TenantBackupHealthAssessment $backupHealth,
?array $recoveryEvidence,
): ?array {
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->forBackupHealth($backupHealth),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->forRecoveryEvidence($recoveryEvidence),
default => null,
};
}
/**
* @return array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* }
* }|null
*/
public function forBackupHealth(?TenantBackupHealthAssessment $assessment): ?array
{
if (! $assessment instanceof TenantBackupHealthAssessment) {
return null;
}
if (! in_array($assessment->posture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
return null;
}
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => $assessment->posture,
'reasonCode' => $assessment->primaryReason,
'severityKey' => $assessment->primaryReason,
'supportingKey' => $assessment->primaryReason,
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concern_state' => $assessment->posture,
'fingerprint' => $this->hash($snapshot),
'snapshot' => $snapshot,
];
}
/**
* @param array<string, mixed>|null $recoveryEvidence
* @return array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* }
* }|null
*/
public function forRecoveryEvidence(?array $recoveryEvidence): ?array
{
$state = is_array($recoveryEvidence)
? TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence)
: null;
if (! in_array($state, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
], true)) {
return null;
}
$reason = is_string($recoveryEvidence['reason'] ?? null)
? $recoveryEvidence['reason']
: null;
$supportingKey = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
? $recoveryEvidence['latest_relevant_attention_state']
: $reason;
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => $state,
'reasonCode' => $reason,
'severityKey' => $reason,
'supportingKey' => $supportingKey,
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concern_state' => $state,
'fingerprint' => $this->hash($snapshot),
'snapshot' => $snapshot,
];
}
/**
* @param array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* } $snapshot
*/
private function hash(array $snapshot): string
{
try {
return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
} catch (JsonException) {
return hash('sha256', implode(':', array_map(
static fn (mixed $value): string => is_scalar($value) || $value === null
? (string) $value
: '',
$snapshot,
)));
}
}
}

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
final readonly class TenantTriageReviewStateResolver
{
public function __construct(
private TenantTriageReviewFingerprint $fingerprints,
) {}
/**
* @param list<int> $tenantIds
* @param array<int, TenantBackupHealthAssessment> $backupHealthByTenant
* @param array<int, array<string, mixed>> $recoveryEvidenceByTenant
* @return array{
* rows: array<int, array{
* backup_health: array<string, mixed>,
* recovery_evidence: array<string, mixed>
* }>,
* summaries: array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }>
* }
*/
public function resolveMany(
int $workspaceId,
array $tenantIds,
array $backupHealthByTenant = [],
array $recoveryEvidenceByTenant = [],
): array {
$tenantIds = array_values(array_unique(array_map(static fn (int|string $tenantId): int => (int) $tenantId, $tenantIds)));
$emptySummary = [
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE),
];
if ($workspaceId <= 0 || $tenantIds === []) {
return [
'rows' => [],
'summaries' => $emptySummary,
];
}
$activeReviews = TenantTriageReview::query()
->with('reviewer:id,name,email')
->forWorkspace($workspaceId)
->whereIn('tenant_id', $tenantIds)
->active()
->orderByDesc('reviewed_at')
->orderByDesc('id')
->get()
->groupBy([
'tenant_id',
'concern_family',
]);
$rows = [];
$summaries = $emptySummary;
foreach ($tenantIds as $tenantId) {
$rows[$tenantId] = [
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->resolveFamily(
tenantId: $tenantId,
concernFamily: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
currentConcern: $this->fingerprints->forBackupHealth($backupHealthByTenant[$tenantId] ?? null),
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH][0] ?? null,
),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->resolveFamily(
tenantId: $tenantId,
concernFamily: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
currentConcern: $this->fingerprints->forRecoveryEvidence($recoveryEvidenceByTenant[$tenantId] ?? null),
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE][0] ?? null,
),
];
foreach ($rows[$tenantId] as $family => $row) {
if (($row['current_concern_present'] ?? false) !== true) {
continue;
}
$summaries[$family]['affected_total']++;
match ($row['derived_state']) {
TenantTriageReview::STATE_REVIEWED => $summaries[$family]['reviewed_count']++,
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $summaries[$family]['follow_up_needed_count']++,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => $summaries[$family]['changed_since_review_count']++,
default => $summaries[$family]['not_reviewed_count']++,
};
}
}
return [
'rows' => $rows,
'summaries' => $summaries,
];
}
/**
* @param array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array<string, mixed>
* }|null $currentConcern
* @return array<string, mixed>
*/
private function resolveFamily(
int $tenantId,
string $concernFamily,
?array $currentConcern,
?TenantTriageReview $activeReview,
): array {
$reviewerName = null;
if ($activeReview?->reviewer !== null) {
$reviewerName = filled($activeReview->reviewer->name)
? (string) $activeReview->reviewer->name
: (filled($activeReview->reviewer->email) ? (string) $activeReview->reviewer->email : null);
}
if ($currentConcern === null) {
return [
'tenant_id' => $tenantId,
'concern_family' => $concernFamily,
'current_concern_present' => false,
'current_state' => null,
'current_fingerprint' => null,
'review_fingerprint' => $activeReview?->review_fingerprint,
'derived_state' => null,
'reviewed_at' => $activeReview?->reviewed_at,
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
'reviewed_by_user_name' => $reviewerName,
'current_snapshot' => null,
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : null,
];
}
$derivedState = match (true) {
! $activeReview instanceof TenantTriageReview => TenantTriageReview::DERIVED_STATE_NOT_REVIEWED,
$activeReview->review_fingerprint !== $currentConcern['fingerprint'] => TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
default => (string) $activeReview->current_state,
};
return [
'tenant_id' => $tenantId,
'concern_family' => $concernFamily,
'current_concern_present' => true,
'current_state' => $currentConcern['concern_state'],
'current_fingerprint' => $currentConcern['fingerprint'],
'review_fingerprint' => $activeReview?->review_fingerprint,
'derived_state' => $derivedState,
'reviewed_at' => $activeReview?->reviewed_at,
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
'reviewed_by_user_name' => $reviewerName,
'current_snapshot' => $currentConcern['snapshot'],
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : $currentConcern['snapshot'],
];
}
/**
* @return array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }
*/
private function emptySummary(string $concernFamily): array
{
return [
'concern_family' => $concernFamily,
'affected_total' => 0,
'reviewed_count' => 0,
'follow_up_needed_count' => 0,
'changed_since_review_count' => 0,
'not_reviewed_count' => 0,
];
}
}

View File

@ -660,6 +660,22 @@ private function resolveTenantWithRecord(?Model $record = null): ?Tenant
} }
} }
if ($this->action instanceof Action) {
$actionRecord = $this->action->getRecord(withDefault: false);
if ($actionRecord instanceof Tenant) {
return $actionRecord;
}
if ($actionRecord instanceof Model && method_exists($actionRecord, 'relationLoaded') && $actionRecord->relationLoaded('tenant')) {
$relatedTenant = $actionRecord->getRelation('tenant');
if ($relatedTenant instanceof Tenant) {
return $relatedTenant;
}
}
}
// If a record is set from forTableAction, try to resolve it // If a record is set from forTableAction, try to resolve it
if ($this->record !== null) { if ($this->record !== null) {
$resolved = $this->record instanceof Closure $resolved = $this->record instanceof Closure

View File

@ -12,6 +12,7 @@
use Closure; use Closure;
use Filament\Actions\Action; use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use ReflectionObject;
use Throwable; use Throwable;
/** /**
@ -39,6 +40,10 @@ final class WorkspaceUiEnforcement
private Model|Closure|null $record = null; private Model|Closure|null $record = null;
private bool $preserveExistingVisibility = false;
private bool $preserveExistingDisabled = false;
private function __construct(Action $action) private function __construct(Action $action)
{ {
$this->action = $action; $this->action = $action;
@ -58,6 +63,14 @@ public static function forTableAction(Action $action, Model|Closure $record): se
return $instance; return $instance;
} }
public static function forAction(Action $action, Model|Closure|null $record = null): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
public function requireMembership(bool $require = true): self public function requireMembership(bool $require = true): self
{ {
$this->requireMembership = $require; $this->requireMembership = $require;
@ -95,6 +108,20 @@ public function tooltip(string $message): self
return $this; return $this;
} }
public function preserveVisibility(): self
{
$this->preserveExistingVisibility = true;
return $this;
}
public function preserveDisabled(): self
{
$this->preserveExistingDisabled = true;
return $this;
}
public function apply(): Action public function apply(): Action
{ {
$this->applyVisibility(); $this->applyVisibility();
@ -111,10 +138,22 @@ private function applyVisibility(): void
return; return;
} }
$this->action->visible(function (?Model $record = null): bool { $existingVisibility = $this->preserveExistingVisibility
? $this->getExistingVisibilityCondition()
: null;
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
$context = $this->resolveContextWithRecord($record); $context = $this->resolveContextWithRecord($record);
return $context->isMember; if (! $context->isMember) {
return false;
}
if ($existingVisibility === null) {
return true;
}
return $this->evaluateVisibilityCondition($existingVisibility, $record);
}); });
} }
@ -126,7 +165,15 @@ private function applyDisabledState(): void
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null): bool { $existingDisabled = $this->preserveExistingDisabled
? $this->getExistingDisabledCondition()
: null;
$this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool {
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
return true;
}
$context = $this->resolveContextWithRecord($record); $context = $this->resolveContextWithRecord($record);
if (! $context->isMember) { if (! $context->isMember) {
@ -173,6 +220,96 @@ private function applyServerSideGuard(): void
}); });
} }
private function getExistingVisibilityCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isVisible')) {
return null;
}
$property = $ref->getProperty('isVisible');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return false;
}
return (bool) $condition($record);
} catch (Throwable) {
return false;
}
}
private function getExistingDisabledCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isDisabled')) {
return null;
}
$property = $ref->getProperty('isDisabled');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return true;
}
return (bool) $condition($record);
} catch (Throwable) {
return true;
}
}
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
{ {
$user = auth()->user(); $user = auth()->user();

View File

@ -30,11 +30,11 @@ public static function tenantsIndex(): string
return Tenants::getUrl(panel: 'system'); return Tenants::getUrl(panel: 'system');
} }
public static function tenantDetail(Tenant|int $tenant): string public static function tenantDetail(Tenant|string|int $tenant): string
{ {
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant; $tenantRouteKey = self::tenantRouteKey($tenant);
return ViewTenant::getUrl(['tenant' => $tenantId], panel: 'system'); return ViewTenant::getUrl(['tenant' => $tenantRouteKey], panel: 'system');
} }
public static function adminWorkspace(Workspace|int $workspace): string public static function adminWorkspace(Workspace|int $workspace): string
@ -44,10 +44,19 @@ public static function adminWorkspace(Workspace|int $workspace): string
return route('filament.admin.resources.workspaces.view', ['record' => $workspaceId]); return route('filament.admin.resources.workspaces.view', ['record' => $workspaceId]);
} }
public static function adminTenant(Tenant|int $tenant): string public static function adminTenant(Tenant|string|int $tenant): string
{ {
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant; $tenantRouteKey = self::tenantRouteKey($tenant);
return route('filament.admin.resources.tenants.view', ['record' => $tenantId]); return route('filament.admin.resources.tenants.view', ['record' => $tenantRouteKey]);
}
private static function tenantRouteKey(Tenant|string|int $tenant): string
{
if ($tenant instanceof Tenant) {
return (string) $tenant->getRouteKey();
}
return (string) $tenant;
} }
} }

View File

@ -6,10 +6,12 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use ReflectionClass;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use RecursiveDirectoryIterator; use RecursiveDirectoryIterator;
use RecursiveIteratorIterator; use RecursiveIteratorIterator;
use SplFileInfo; use SplFileInfo;
use Throwable;
final class ActionSurfaceDiscovery final class ActionSurfaceDiscovery
{ {
@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
{ {
$scopes = [ActionSurfacePanelScope::Tenant]; $scopes = [ActionSurfacePanelScope::Tenant];
if (in_array($className, $adminScopedClasses, true)) { if (
in_array($className, $adminScopedClasses, true)
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
) {
$scopes[] = ActionSurfacePanelScope::Admin; $scopes[] = ActionSurfacePanelScope::Admin;
} }
@ -228,6 +233,37 @@ private function isDeclaredSystemTablePage(string $className): bool
&& method_exists($className, 'actionSurfaceDeclaration'); && method_exists($className, 'actionSurfaceDeclaration');
} }
/**
* Resource-owned Filament pages can live under app/Filament/Pages and be routed
* from the resource instead of being panel-registered directly. When that happens,
* inherit admin scope from the owning resource so discovery stays truthful.
*
* @param array<int, string> $adminScopedClasses
*/
private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool
{
if (! class_exists($className)) {
return false;
}
try {
$reflection = new ReflectionClass($className);
if (! $reflection->hasProperty('resource')) {
return false;
}
$defaults = $reflection->getDefaultProperties();
$resourceClass = $defaults['resource'] ?? null;
return is_string($resourceClass)
&& $resourceClass !== ''
&& in_array($resourceClass, $adminScopedClasses, true);
} catch (Throwable) {
return false;
}
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */

View File

@ -4,6 +4,20 @@
namespace App\Support\Ui\ActionSurface; namespace App\Support\Ui\ActionSurface;
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot;
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion;
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
use App\Filament\Resources\TenantResource\Pages\EditTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies; use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
final class ActionSurfaceExemptions final class ActionSurfaceExemptions
@ -49,4 +63,275 @@ public function hasClass(string $className): bool
{ {
return array_key_exists($className, $this->componentReasons); return array_key_exists($className, $this->componentReasons);
} }
/**
* @return array<string, array{
* surfaceKey: string,
* classification: string,
* canonicalNoun: string,
* panelScope: string,
* ownerScope: string,
* routeKind: string,
* requiresHeaderRemediation: bool,
* exceptionReason: ?string,
* maxVisiblePrimaryActions: int,
* allowsNoPrimaryAction: bool,
* requiresGroupedSecondaryActions: bool,
* requiresDangerSeparation: bool,
* allowsPrimaryNavigation: bool,
* browserSmokeRequired: bool
* }>
*/
public static function spec192RecordPageInventory(): array
{
return [
ViewBaselineProfile::class => [
'surfaceKey' => 'baseline_profile_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Baseline profile',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => false,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewEvidenceSnapshot::class => [
'surfaceKey' => 'evidence_snapshot_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Evidence snapshot',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => false,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewFindingException::class => [
'surfaceKey' => 'finding_exception_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Finding exception',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewTenantReview::class => [
'surfaceKey' => 'tenant_review_view',
'classification' => 'remediation_required',
'canonicalNoun' => 'Tenant review',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => false,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
EditTenant::class => [
'surfaceKey' => 'tenant_edit',
'classification' => 'remediation_required',
'canonicalNoun' => 'Tenant',
'panelScope' => 'admin',
'ownerScope' => 'tenant-owned',
'routeKind' => 'edit',
'requiresHeaderRemediation' => true,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewTenant::class => [
'surfaceKey' => 'tenant_view',
'classification' => 'workflow_heavy_special_type',
'canonicalNoun' => 'Tenant',
'panelScope' => 'admin',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => 'Tenant detail remains a workflow-heavy hub for external links, verification/setup, and lifecycle operations. It may show one dominant next step, but it must never silently fall back to a flat multi-button strip.',
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => false,
'browserSmokeRequired' => true,
],
ViewProviderConnection::class => [
'surfaceKey' => 'provider_connection_view',
'classification' => 'minor_alignment_only',
'canonicalNoun' => 'Provider connection',
'panelScope' => 'admin',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => false,
],
ViewFinding::class => [
'surfaceKey' => 'finding_view',
'classification' => 'minor_alignment_only',
'canonicalNoun' => 'Finding',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => false,
],
ViewReviewPack::class => [
'surfaceKey' => 'review_pack_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Review pack',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewAlertDestination::class => [
'surfaceKey' => 'alert_destination_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Alert destination',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewPolicyVersion::class => [
'surfaceKey' => 'policy_version_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Policy version',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewWorkspace::class => [
'surfaceKey' => 'workspace_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Workspace',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewBaselineSnapshot::class => [
'surfaceKey' => 'baseline_snapshot_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Baseline snapshot',
'panelScope' => 'admin',
'ownerScope' => 'workspace-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => false,
'requiresDangerSeparation' => false,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
ViewBackupSet::class => [
'surfaceKey' => 'backup_set_view',
'classification' => 'compliant_reference',
'canonicalNoun' => 'Backup set',
'panelScope' => 'tenant',
'ownerScope' => 'tenant-owned',
'routeKind' => 'view',
'requiresHeaderRemediation' => false,
'exceptionReason' => null,
'maxVisiblePrimaryActions' => 1,
'allowsNoPrimaryAction' => true,
'requiresGroupedSecondaryActions' => true,
'requiresDangerSeparation' => true,
'allowsPrimaryNavigation' => true,
'browserSmokeRequired' => true,
],
];
}
/**
* @return array{
* surfaceKey: string,
* classification: string,
* canonicalNoun: string,
* panelScope: string,
* ownerScope: string,
* routeKind: string,
* requiresHeaderRemediation: bool,
* exceptionReason: ?string,
* maxVisiblePrimaryActions: int,
* allowsNoPrimaryAction: bool,
* requiresGroupedSecondaryActions: bool,
* requiresDangerSeparation: bool,
* allowsPrimaryNavigation: bool,
* browserSmokeRequired: bool
* }|null
*/
public static function spec192RecordPageSurface(string $className): ?array
{
return self::spec192RecordPageInventory()[$className] ?? null;
}
} }

View File

@ -54,6 +54,8 @@ public function validateComponents(array $components): ActionSurfaceValidationRe
{ {
$issues = []; $issues = [];
$this->validateSpec192RecordPageInventory($issues);
foreach ($components as $component) { foreach ($components as $component) {
if (! class_exists($component->className)) { if (! class_exists($component->className)) {
$issues[] = new ActionSurfaceValidationIssue( $issues[] = new ActionSurfaceValidationIssue(
@ -106,6 +108,128 @@ className: $component->className,
); );
} }
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateSpec192RecordPageInventory(array &$issues): void
{
$allowedClassifications = [
'remediation_required',
'minor_alignment_only',
'compliant_reference',
'workflow_heavy_special_type',
];
$allowedPanelScopes = ['admin', 'tenant'];
$allowedOwnerScopes = ['workspace-owned', 'tenant-owned'];
$allowedRouteKinds = ['view', 'edit'];
$surfaceKeys = [];
foreach (ActionSurfaceExemptions::spec192RecordPageInventory() as $className => $surface) {
if (! class_exists($className)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 inventory references a page class that does not exist.',
hint: 'Keep ActionSurfaceExemptions::spec192RecordPageInventory() aligned with the in-scope page classes.',
);
continue;
}
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
if ($surfaceKey === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 inventory entry is missing a non-empty surface key.',
hint: 'Provide the stable spec surface key for this page.',
);
} elseif (isset($surfaceKeys[$surfaceKey])) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf('Spec 192 surface key "%s" is declared more than once.', $surfaceKey),
hint: 'Each in-scope page must have a unique surface key.',
);
} else {
$surfaceKeys[$surfaceKey] = true;
}
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 classification is invalid or missing.',
hint: 'Use remediation_required, minor_alignment_only, compliant_reference, or workflow_heavy_special_type.',
);
}
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 panel scope is invalid or missing.',
hint: 'Use the concrete panel scope for the record page inventory entry.',
);
}
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 owner scope is invalid or missing.',
hint: 'Use workspace-owned or tenant-owned.',
);
}
if (! in_array($surface['routeKind'] ?? null, $allowedRouteKinds, true)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 route kind is invalid or missing.',
hint: 'Use view or edit.',
);
}
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 canonical noun must be non-empty.',
hint: 'Use the stable operator-facing noun for the surface.',
);
}
$classification = (string) ($surface['classification'] ?? '');
$exceptionReason = $surface['exceptionReason'] ?? null;
if ($classification === 'workflow_heavy_special_type') {
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Workflow-heavy Spec 192 pages require an explicit exception reason.',
hint: 'Document why this surface is intentionally exempt from the standard record-page rule.',
);
}
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Only workflow-heavy Spec 192 pages may carry an exception reason.',
hint: 'Clear the exception reason for standard, minor-alignment, and compliant-reference surfaces.',
);
}
if (($surface['maxVisiblePrimaryActions'] ?? null) !== 1) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Spec 192 maxVisiblePrimaryActions must stay pinned to 1.',
hint: 'The bounded header contract allows at most one visible primary header action.',
);
}
if ($classification === 'remediation_required' && ($surface['allowsPrimaryNavigation'] ?? false) === true) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Remediation-required Spec 192 surfaces must not allow primary navigation.',
hint: 'Move pure navigation into contextual placement outside the header.',
);
}
}
}
/** /**
* @param array<int, ActionSurfaceValidationIssue> $issues * @param array<int, ActionSurfaceValidationIssue> $issues
*/ */

View File

@ -13,6 +13,7 @@
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
@ -31,6 +32,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\RestoreSafety\RestoreSafetyCopy;
@ -47,6 +49,7 @@ public function __construct(
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver, private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver, private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver, private RestoreSafetyResolver $restoreSafetyResolver,
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
) {} ) {}
/** /**
@ -66,6 +69,7 @@ public function build(Workspace $workspace, User $user): array
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW); $canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
$navigationContext = $this->workspaceOverviewNavigationContext(); $navigationContext = $this->workspaceOverviewNavigationContext();
$tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts); $tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts);
$triageReviewProgress = $this->triageReviewProgress($workspaceId, $tenantContexts);
$attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext); $attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext);
$governanceAttentionTenantCount = count(array_filter( $governanceAttentionTenantCount = count(array_filter(
@ -141,6 +145,7 @@ public function build(Workspace $workspace, User $user): array
totalAlertFailuresCount: $totalAlertFailuresCount, totalAlertFailuresCount: $totalAlertFailuresCount,
canViewAlerts: $canViewAlerts, canViewAlerts: $canViewAlerts,
tenantContexts: $tenantContexts, tenantContexts: $tenantContexts,
triageReviewSummaries: $triageReviewProgress['summaries'],
user: $user, user: $user,
navigationContext: $navigationContext, navigationContext: $navigationContext,
); );
@ -164,6 +169,7 @@ public function build(Workspace $workspace, User $user): array
'workspace_name' => (string) $workspace->name, 'workspace_name' => (string) $workspace->name,
'accessible_tenant_count' => $accessibleTenants->count(), 'accessible_tenant_count' => $accessibleTenants->count(),
'summary_metrics' => $summaryMetrics, 'summary_metrics' => $summaryMetrics,
'triage_review_progress' => $triageReviewProgress['families'],
'attention_items' => $attentionItems, 'attention_items' => $attentionItems,
'attention_empty_state' => $attentionEmptyState, 'attention_empty_state' => $attentionEmptyState,
'recent_operations' => $recentOperations, 'recent_operations' => $recentOperations,
@ -828,6 +834,7 @@ private function summaryMetrics(
int $totalAlertFailuresCount, int $totalAlertFailuresCount,
bool $canViewAlerts, bool $canViewAlerts,
array $tenantContexts, array $tenantContexts,
array $triageReviewSummaries,
User $user, User $user,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
@ -861,7 +868,11 @@ private function summaryMetrics(
label: 'Backup attention', label: 'Backup attention',
value: $backupAttentionTenantCount, value: $backupAttentionTenantCount,
category: 'backup_health', category: 'backup_health',
description: 'Visible tenants with non-healthy backup posture.', description: $this->reviewSummaryMetricDescription(
family: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
baseDescription: 'Visible tenants with non-healthy backup posture.',
triageReviewSummaries: $triageReviewSummaries,
),
color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray', color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray',
destination: $this->attentionMetricDestination( destination: $this->attentionMetricDestination(
tenantContexts: $tenantContexts, tenantContexts: $tenantContexts,
@ -874,7 +885,11 @@ private function summaryMetrics(
label: 'Recovery attention', label: 'Recovery attention',
value: $recoveryAttentionTenantCount, value: $recoveryAttentionTenantCount,
category: 'recovery_evidence', category: 'recovery_evidence',
description: 'Visible tenants with weakened or unvalidated recovery evidence.', description: $this->reviewSummaryMetricDescription(
family: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
baseDescription: 'Visible tenants with weakened or unvalidated recovery evidence.',
triageReviewSummaries: $triageReviewSummaries,
),
color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray', color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray',
destination: $this->attentionMetricDestination( destination: $this->attentionMetricDestination(
tenantContexts: $tenantContexts, tenantContexts: $tenantContexts,
@ -912,6 +927,83 @@ private function summaryMetrics(
return $metrics; return $metrics;
} }
/**
* @param list<array<string, mixed>> $tenantContexts
* @return array{
* summaries: array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }>,
* families: list<array<string, mixed>>
* }
*/
private function triageReviewProgress(int $workspaceId, array $tenantContexts): array
{
$tenantIds = [];
$backupHealthByTenant = [];
$recoveryEvidenceByTenant = [];
foreach ($tenantContexts as $context) {
$tenant = $context['tenant'] ?? null;
if (! $tenant instanceof Tenant) {
continue;
}
$tenantId = (int) $tenant->getKey();
$tenantIds[] = $tenantId;
$backupHealthByTenant[$tenantId] = $context['backup_health_assessment'] ?? null;
$recoveryEvidenceByTenant[$tenantId] = $context['recovery_evidence'] ?? null;
}
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
workspaceId: $workspaceId,
tenantIds: $tenantIds,
backupHealthByTenant: $backupHealthByTenant,
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
);
return [
'summaries' => $resolved['summaries'],
'families' => $this->triageReviewProgressFamilies($resolved['summaries']),
];
}
/**
* @param array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }> $triageReviewSummaries
*/
private function reviewSummaryMetricDescription(
string $family,
string $baseDescription,
array $triageReviewSummaries,
): string {
$summary = $triageReviewSummaries[$family] ?? null;
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
return $baseDescription;
}
return sprintf(
'%s Reviewed %d/%d. Follow-up needed %d. Changed since review %d.',
$baseDescription,
(int) ($summary['reviewed_count'] ?? 0),
(int) ($summary['affected_total'] ?? 0),
(int) ($summary['follow_up_needed_count'] ?? 0),
(int) ($summary['changed_since_review_count'] ?? 0),
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -938,6 +1030,77 @@ private function makeSummaryMetric(
]; ];
} }
/**
* @param array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }> $triageReviewSummaries
* @return list<array<string, mixed>>
*/
private function triageReviewProgressFamilies(array $triageReviewSummaries): array
{
$families = [];
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
$summary = $triageReviewSummaries[$family] ?? null;
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
continue;
}
$families[] = [
'concern_family' => $family,
'label' => $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH ? 'Backup health' : 'Recovery evidence',
'affected_total' => (int) ($summary['affected_total'] ?? 0),
'reviewed_count' => (int) ($summary['reviewed_count'] ?? 0),
'follow_up_needed_count' => (int) ($summary['follow_up_needed_count'] ?? 0),
'changed_since_review_count' => (int) ($summary['changed_since_review_count'] ?? 0),
'not_reviewed_count' => (int) ($summary['not_reviewed_count'] ?? 0),
'reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_REVIEWED, 'Reviewed'),
'follow_up_needed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, 'Follow-up needed'),
'changed_since_review_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, 'Changed since review'),
'not_reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_NOT_REVIEWED, 'Not reviewed'),
];
}
return $families;
}
/**
* @return array<string, mixed>
*/
private function triageReviewBucketDestination(string $family, string $reviewState, string $label): array
{
$filters = match ($family) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => [
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'review_state' => [$reviewState],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => [
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [$reviewState],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
default => [
'review_state' => [$reviewState],
],
};
return $this->filteredTenantRegistryTarget($filters, $label);
}
/** /**
* @param list<array<string, mixed>> $tenantContexts * @param list<array<string, mixed>> $tenantContexts
*/ */

View File

@ -47,15 +47,14 @@ public function definition(): array
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'display_name' => fake()->company(), 'display_name' => fake()->company(),
'is_default' => false, 'is_default' => false,
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => 'needs_consent',
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => 'unknown',
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'scopes_granted' => [], 'scopes_granted' => [],
@ -83,7 +82,7 @@ public function dedicated(): static
public function consentGranted(): static public function consentGranted(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => 'connected', 'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'consent_granted_at' => now(), 'consent_granted_at' => now(),
'consent_last_checked_at' => now(), 'consent_last_checked_at' => now(),
@ -93,13 +92,19 @@ public function consentGranted(): static
public function verifiedHealthy(): static public function verifiedHealthy(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => 'connected', 'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'consent_granted_at' => now(), 'consent_granted_at' => now(),
'consent_last_checked_at' => now(), 'consent_last_checked_at' => now(),
'verification_status' => ProviderVerificationStatus::Healthy->value, 'verification_status' => ProviderVerificationStatus::Healthy->value,
'health_status' => 'ok',
'last_health_check_at' => now(), 'last_health_check_at' => now(),
]); ]);
} }
public function disabled(): static
{
return $this->state(fn (): array => [
'is_enabled' => false,
]);
}
} }

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TenantTriageReview>
*/
class TenantTriageReviewFactory extends Factory
{
protected $model = TenantTriageReview::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => 'stale',
'reasonCode' => 'latest_backup_stale',
'severityKey' => 'latest_backup_stale',
'supportingKey' => 'latest_backup_stale',
];
return [
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
'workspace_id' => function (array $attributes): int {
$tenantId = $attributes['tenant_id'] ?? null;
if (! is_numeric($tenantId)) {
return (int) Workspace::factory()->create()->getKey();
}
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
if (! $tenant instanceof Tenant || ! is_numeric($tenant->workspace_id)) {
return (int) Workspace::factory()->create()->getKey();
}
return (int) $tenant->workspace_id;
},
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'current_state' => TenantTriageReview::STATE_REVIEWED,
'reviewed_at' => now()->subMinutes(5),
'reviewed_by_user_id' => User::factory(),
'review_fingerprint' => $this->hashSnapshot($snapshot),
'review_snapshot' => $snapshot,
'last_seen_matching_at' => now()->subMinutes(5),
'resolved_at' => null,
];
}
public function reviewed(): static
{
return $this->state(fn (): array => [
'current_state' => TenantTriageReview::STATE_REVIEWED,
]);
}
public function followUpNeeded(): static
{
return $this->state(fn (): array => [
'current_state' => TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
]);
}
public function backupHealth(): static
{
return $this->state(function (): array {
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => 'stale',
'reasonCode' => 'latest_backup_stale',
'severityKey' => 'latest_backup_stale',
'supportingKey' => 'latest_backup_stale',
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $this->hashSnapshot($snapshot),
'review_snapshot' => $snapshot,
];
});
}
public function recoveryEvidence(): static
{
return $this->state(function (): array {
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => 'weakened',
'reasonCode' => 'failed',
'severityKey' => 'failed',
'supportingKey' => 'failed',
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'review_fingerprint' => $this->hashSnapshot($snapshot),
'review_snapshot' => $snapshot,
];
});
}
public function resolved(): static
{
return $this->state(fn (): array => [
'resolved_at' => now()->subMinute(),
]);
}
public function active(): static
{
return $this->state(fn (): array => [
'resolved_at' => null,
]);
}
public function changedFingerprint(): static
{
return $this->state(fn (): array => [
'review_fingerprint' => hash('sha256', 'changed-fingerprint-'.fake()->uuid()),
]);
}
/**
* @param array<string, mixed> $snapshot
*/
private function hashSnapshot(array $snapshot): string
{
return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('provider_connections', function (Blueprint $table): void {
$table->boolean('is_enabled')->default(true);
});
DB::table('provider_connections')
->where('status', 'disabled')
->update(['is_enabled' => false]);
}
public function down(): void
{
Schema::table('provider_connections', function (Blueprint $table): void {
$table->dropColumn('is_enabled');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('provider_connections', function (Blueprint $table): void {
$table->dropIndex(['tenant_id', 'provider', 'status']);
$table->dropIndex(['tenant_id', 'provider', 'health_status']);
$table->dropIndex(['workspace_id', 'provider', 'status']);
$table->dropIndex(['workspace_id', 'provider', 'health_status']);
$table->dropColumn(['status', 'health_status']);
});
}
public function down(): void
{
Schema::table('provider_connections', function (Blueprint $table): void {
$table->string('status')->default('needs_consent');
$table->string('health_status')->default('unknown');
$table->index(['tenant_id', 'provider', 'status']);
$table->index(['tenant_id', 'provider', 'health_status']);
$table->index(['workspace_id', 'provider', 'status']);
$table->index(['workspace_id', 'provider', 'health_status']);
});
}
};

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_triage_reviews', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('concern_family', 64);
$table->string('current_state', 64);
$table->timestampTz('reviewed_at');
$table->foreignId('reviewed_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('review_fingerprint', 64);
$table->jsonb('review_snapshot')->default('{}');
$table->timestampTz('last_seen_matching_at')->nullable();
$table->timestampTz('resolved_at')->nullable();
$table->timestamps();
$table->index(
['workspace_id', 'concern_family', 'resolved_at', 'tenant_id'],
'tenant_triage_reviews_lookup_index',
);
$table->index(
['tenant_id', 'concern_family', 'resolved_at'],
'tenant_triage_reviews_tenant_family_index',
);
});
DB::statement("
CREATE UNIQUE INDEX tenant_triage_reviews_active_unique
ON tenant_triage_reviews (workspace_id, tenant_id, concern_family)
WHERE resolved_at IS NULL
");
if (DB::getDriverName() !== 'sqlite') {
DB::statement("
ALTER TABLE tenant_triage_reviews
ADD CONSTRAINT tenant_triage_reviews_current_state_check
CHECK (current_state IN ('reviewed', 'follow_up_needed'))
");
}
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS tenant_triage_reviews_active_unique');
if (DB::getDriverName() !== 'sqlite') {
DB::statement('ALTER TABLE tenant_triage_reviews DROP CONSTRAINT IF EXISTS tenant_triage_reviews_current_state_check');
}
Schema::dropIfExists('tenant_triage_reviews');
}
};

View File

@ -10,6 +10,7 @@
window.__tenantpilotUnhandledRejectionLoggerApplied = true; window.__tenantpilotUnhandledRejectionLoggerApplied = true;
const recentKeys = new Map(); const recentKeys = new Map();
const recentTransportFailures = [];
const cleanupRecentKeys = (nowMs) => { const cleanupRecentKeys = (nowMs) => {
for (const [key, timestampMs] of recentKeys.entries()) { for (const [key, timestampMs] of recentKeys.entries()) {
@ -19,6 +20,258 @@
} }
}; };
const cleanupRecentTransportFailures = (nowMs) => {
while (recentTransportFailures.length > 0 && nowMs - recentTransportFailures[0].timestampMs > 15_000) {
recentTransportFailures.shift();
}
};
const normalizeUrl = (value) => {
if (typeof value !== 'string' || value === '') {
return null;
}
try {
return new URL(value, window.location.href).href;
} catch {
return value;
}
};
const toBodySnippet = (body) => {
if (typeof body !== 'string' || body === '') {
return null;
}
return body.slice(0, 1_000);
};
const recordTransportFailure = ({ requestUrl, method, status, body, transportType }) => {
const nowMs = Date.now();
cleanupRecentTransportFailures(nowMs);
recentTransportFailures.push({
requestUrl: normalizeUrl(requestUrl),
method: typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET',
status: Number.isFinite(status) ? status : null,
bodySnippet: toBodySnippet(body),
transportType: typeof transportType === 'string' && transportType !== '' ? transportType : 'unknown',
timestampMs: nowMs,
});
if (recentTransportFailures.length > 30) {
recentTransportFailures.shift();
}
};
const resolveTransportMetadata = (reason) => {
if (reason === null || typeof reason !== 'object') {
return null;
}
const directRequestUrl = typeof reason.requestUrl === 'string'
? normalizeUrl(reason.requestUrl)
: (typeof reason.url === 'string' ? normalizeUrl(reason.url) : null);
if (directRequestUrl) {
return {
requestUrl: directRequestUrl,
method: typeof reason.method === 'string' ? reason.method.toUpperCase() : null,
transportType: 'reason',
};
}
if (!isTransportEnvelope(reason)) {
return null;
}
const nowMs = Date.now();
const reasonBodySnippet = toBodySnippet(reason.body);
cleanupRecentTransportFailures(nowMs);
for (let index = recentTransportFailures.length - 1; index >= 0; index -= 1) {
const candidate = recentTransportFailures[index];
if (nowMs - candidate.timestampMs > 5_000) {
break;
}
if (candidate.status !== reason.status) {
continue;
}
if (reasonBodySnippet !== null && candidate.bodySnippet !== null && candidate.bodySnippet !== reasonBodySnippet) {
continue;
}
return {
requestUrl: candidate.requestUrl,
method: candidate.method,
transportType: candidate.transportType,
};
}
return null;
};
const extractRequestMetadata = (input, init) => {
if (input instanceof Request) {
return {
requestUrl: normalizeUrl(input.url),
method: typeof input.method === 'string' && input.method !== ''
? input.method.toUpperCase()
: 'GET',
};
}
return {
requestUrl: normalizeUrl(typeof input === 'string' ? input : String(input ?? '')),
method: typeof init?.method === 'string' && init.method !== ''
? init.method.toUpperCase()
: 'GET',
};
};
if (typeof window.fetch === 'function' && !window.__tenantpilotUnhandledRejectionFetchInstrumented) {
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const [input, init] = args;
const transport = extractRequestMetadata(input, init);
try {
const response = await originalFetch(...args);
if (!response.ok) {
const clonedResponse = typeof response.clone === 'function' ? response.clone() : null;
if (clonedResponse && typeof clonedResponse.text === 'function') {
clonedResponse.text()
.then((body) => {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: response.status,
body,
transportType: 'fetch',
});
})
.catch(() => {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: response.status,
body: null,
transportType: 'fetch',
});
});
} else {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: response.status,
body: null,
transportType: 'fetch',
});
}
}
return response;
} catch (error) {
recordTransportFailure({
requestUrl: transport.requestUrl,
method: transport.method,
status: null,
body: error instanceof Error ? error.message : String(error ?? ''),
transportType: 'fetch',
});
throw error;
}
};
window.__tenantpilotUnhandledRejectionFetchInstrumented = true;
}
if (typeof XMLHttpRequest !== 'undefined' && !window.__tenantpilotUnhandledRejectionXhrInstrumented) {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__tenantpilotRequestMethod = typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET';
this.__tenantpilotRequestUrl = normalizeUrl(typeof url === 'string' ? url : String(url ?? ''));
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
if (!this.__tenantpilotTransportFailureListenerApplied) {
this.addEventListener('loadend', () => {
if (typeof this.status === 'number' && this.status >= 400) {
recordTransportFailure({
requestUrl: this.__tenantpilotRequestUrl,
method: this.__tenantpilotRequestMethod,
status: this.status,
body: typeof this.responseText === 'string' ? this.responseText : null,
transportType: 'xhr',
});
}
});
this.__tenantpilotTransportFailureListenerApplied = true;
}
return originalSend.apply(this, args);
};
window.__tenantpilotUnhandledRejectionXhrInstrumented = true;
}
const isTransportEnvelope = (value) => {
return value !== null
&& typeof value === 'object'
&& Object.prototype.hasOwnProperty.call(value, 'status')
&& Object.prototype.hasOwnProperty.call(value, 'body')
&& Object.prototype.hasOwnProperty.call(value, 'json')
&& Object.prototype.hasOwnProperty.call(value, 'errors');
};
const isCancellationReason = (reason) => {
if (!isTransportEnvelope(reason)) {
return false;
}
return reason.status === null
&& reason.body === null
&& reason.json === null
&& reason.errors === null;
};
const isPageHiddenOrInactive = () => {
if (document.visibilityState !== 'visible') {
return true;
}
return typeof document.hasFocus === 'function'
? document.hasFocus() === false
: false;
};
const isExpectedBackgroundTransportFailure = (reason) => {
if (isCancellationReason(reason)) {
return true;
}
if (!isTransportEnvelope(reason) || !isPageHiddenOrInactive()) {
return false;
}
return (reason.status === 419 && typeof reason.body === 'string' && reason.body.includes('Page Expired'))
|| (reason.status === 404 && typeof reason.body === 'string' && reason.body.includes('Not Found'));
};
const normalizeReason = (value, depth = 0) => { const normalizeReason = (value, depth = 0) => {
if (depth > 3) { if (depth > 3) {
return '[max-depth-reached]'; return '[max-depth-reached]';
@ -58,6 +311,9 @@
'errors', 'errors',
'reason', 'reason',
'code', 'code',
'url',
'requestUrl',
'method',
]; ];
for (const key of allowedKeys) { for (const key of allowedKeys) {
@ -95,23 +351,41 @@
}; };
window.addEventListener('unhandledrejection', (event) => { window.addEventListener('unhandledrejection', (event) => {
const normalizedReason = normalizeReason(event.reason);
const transport = resolveTransportMetadata(normalizedReason);
const payload = { const payload = {
source: 'window.unhandledrejection', source: 'window.unhandledrejection',
href: window.location.href, href: window.location.href,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
reason: normalizeReason(event.reason), requestUrl: transport?.requestUrl ?? null,
requestMethod: transport?.method ?? null,
transportType: transport?.transportType ?? null,
reason: normalizedReason,
}; };
if (isExpectedBackgroundTransportFailure(normalizedReason)) {
event.preventDefault();
return;
}
const dedupeKey = toStableJson({
source: payload.source,
href: payload.href,
requestUrl: payload.requestUrl,
reason: payload.reason,
});
const payloadJson = toStableJson(payload); const payloadJson = toStableJson(payload);
const nowMs = Date.now(); const nowMs = Date.now();
cleanupRecentKeys(nowMs); cleanupRecentKeys(nowMs);
if (recentKeys.has(payloadJson)) { if (recentKeys.has(dedupeKey)) {
return; return;
} }
recentKeys.set(payloadJson, nowMs); recentKeys.set(dedupeKey, nowMs);
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`); console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
}); });

View File

@ -8,18 +8,17 @@
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null; $provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
$lifecycle = is_string($state['lifecycle'] ?? null) ? (string) $state['lifecycle'] : null;
$isEnabled = $state['is_enabled'] ?? null;
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null; $consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null; $verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
$isMissing = $connectionState === 'missing'; $isMissing = $connectionState === 'missing';
$lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle);
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus); $consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus); $verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
$legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status);
$legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus);
@endphp @endphp
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm"> <div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
@ -52,6 +51,14 @@
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd> <dd>{{ $provider ?? 'n/a' }}</dd>
</div> </div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
<dd>
<x-filament::badge :color="$lifecycleSpec->color" :icon="$lifecycleSpec->icon" size="sm">
{{ $lifecycleSpec->label }}
</x-filament::badge>
</dd>
</div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
<dd> <dd>
@ -76,25 +83,6 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700"> <div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
<dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
<dd>
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
{{ $legacyStatusSpec->label }}
</x-filament::badge>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
<dd>
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
{{ $legacyHealthSpec->label }}
</x-filament::badge>
</dd>
</div>
</dl>
@if ($lastErrorReason) @if ($lastErrorReason)
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800"> <div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
Last error reason: {{ $lastErrorReason }} Last error reason: {{ $lastErrorReason }}

View File

@ -5,6 +5,7 @@
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : []; $metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : []; $highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : []; $nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : []; $publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : []; $operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
@endphp @endphp
@ -72,6 +73,37 @@
</div> </div>
@endif @endif
@if ($contextLinks !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
<div class="grid gap-3 md:grid-cols-3">
@foreach ($contextLinks as $link)
@php
$title = is_string($link['title'] ?? null) ? $link['title'] : null;
$label = is_string($link['label'] ?? null) ? $link['label'] : null;
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
@endphp
@continue($title === null || $label === null || $url === null)
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $title }}</div>
<div class="mt-2">
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
{{ $label }}
</x-filament::link>
</div>
@if ($description !== null && trim($description) !== '')
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $description }}</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>

View File

@ -9,6 +9,8 @@
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0); $duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null; $explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null; $summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []); $explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null) $evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult']) ? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
@ -26,6 +28,28 @@
}; };
@endphp @endphp
@if ($arrivedFromCompareMatrix)
<x-filament::section>
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" icon="heroicon-m-squares-2x2" size="sm">
Arrived from compare matrix
</x-filament::badge>
@if ($matrixBaselineProfileId)
<x-filament::badge color="gray" size="sm">
Baseline profile #{{ (int) $matrixBaselineProfileId }}
</x-filament::badge>
@endif
@if (filled($matrixSubjectKey))
<x-filament::badge color="gray" size="sm">
Subject {{ $matrixSubjectKey }}
</x-filament::badge>
@endif
</div>
</x-filament::section>
@endif
@if ($duplicateNamePoliciesCountValue > 0) @if ($duplicateNamePoliciesCountValue > 0)
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40"> <div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">

View File

@ -0,0 +1,868 @@
<x-filament::page>
@php
$reference = is_array($reference ?? null) ? $reference : [];
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
$denseRows = is_array($denseRows ?? null) ? $denseRows : [];
$compactResults = is_array($compactResults ?? null) ? $compactResults : [];
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
$tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : [];
$subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : [];
$stateLegend = is_array($stateLegend ?? null) ? $stateLegend : [];
$freshnessLegend = is_array($freshnessLegend ?? null) ? $freshnessLegend : [];
$trustLegend = is_array($trustLegend ?? null) ? $trustLegend : [];
$emptyState = is_array($emptyState ?? null) ? $emptyState : null;
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
$draftFilters = is_array($draftFilters ?? null) ? $draftFilters : [];
$presentationState = is_array($presentationState ?? null) ? $presentationState : [];
$supportSurfaceState = is_array($supportSurfaceState ?? null) ? $supportSurfaceState : [];
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
$activeFilterCount = $this->activeFilterCount();
$activeFilterSummary = $this->activeFilterSummary();
$stagedFilterSummary = $this->stagedFilterSummary();
$hasStagedFilterChanges = (bool) ($presentationState['hasStagedFilterChanges'] ?? false);
$requestedMode = (string) ($presentationState['requestedMode'] ?? 'auto');
$resolvedMode = (string) ($presentationState['resolvedMode'] ?? 'compact');
$visibleTenantCount = (int) ($presentationState['visibleTenantCount'] ?? 0);
$autoRefreshActive = (bool) ($presentationState['autoRefreshActive'] ?? false);
$lastUpdatedAt = $presentationState['lastUpdatedAt'] ?? null;
$compactModeAvailable = (bool) ($presentationState['compactModeAvailable'] ?? false);
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - $visibleTenantCount);
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
$trustBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixTrust, $value);
$severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value);
$profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value);
$profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null);
$modeBadgeColor = match ($resolvedMode) {
'dense' => 'info',
'compact' => 'success',
default => 'gray',
};
$modeLabel = $this->presentationModeLabel($resolvedMode);
@endphp
@if ($autoRefreshActive)
<div aria-hidden="true" wire:poll.5s="pollMatrix"></div>
@endif
<x-filament::section heading="Reference overview">
<x-slot name="description">
Compare assigned tenants remains simulation only. This operator view changes presentation density, not compare truth, visible-set scope, or the existing drilldown path.
</x-slot>
<div class="space-y-4">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$profileStatusSpec->color" :icon="$profileStatusSpec->icon" size="sm">
{{ $profileStatusSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$referenceReady ? 'success' : 'warning'" :icon="$referenceReady ? 'heroicon-m-check-badge' : 'heroicon-m-exclamation-triangle'" size="sm">
{{ $referenceReady ? 'Reference snapshot ready' : 'Reference snapshot blocked' }}
</x-filament::badge>
<x-filament::badge :color="$modeBadgeColor" size="sm">
{{ $modeLabel }}
</x-filament::badge>
@if (filled($reference['referenceSnapshotId'] ?? null))
<x-filament::badge color="gray" size="sm">
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
</x-filament::badge>
@endif
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
{{ $hiddenAssignedTenantCount }} hidden by access scope
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<h2 class="text-xl font-semibold text-gray-950 dark:text-white" data-testid="baseline-compare-matrix-profile">
{{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}.
Visible tenants: {{ $visibleTenantCount }}.
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
@endif
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
Auto mode resolves from the visible tenant set. Manual mode stays local to this route and never becomes stored preference truth.
</p>
@if (filled($reference['referenceReasonCode'] ?? null))
<p class="text-sm text-warning-700 dark:text-warning-300">
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
</p>
@endif
</div>
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:w-[28rem]">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tenants</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $visibleTenantCount }}
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Rendered subjects</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $resolvedMode === 'compact' ? count($compactResults) : count($denseRows) }}
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Active filters</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
@if ($activeFilterCount === 0)
All visible results
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
@endif
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Resolved mode</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $modeLabel }}
</dd>
</div>
</dl>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(19rem,23rem)]">
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="baseline-compare-matrix-mode-switcher">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Presentation mode</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Requested: {{ $this->presentationModeLabel($requestedMode) }}. Resolved: {{ $modeLabel }}.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button tag="a" :href="$this->modeUrl('auto')" :color="$requestedMode === 'auto' ? 'primary' : 'gray'" size="sm">
Auto
</x-filament::button>
<x-filament::button tag="a" :href="$this->modeUrl('dense')" :color="$requestedMode === 'dense' ? 'primary' : 'gray'" size="sm">
Dense
</x-filament::button>
@if ($compactModeAvailable)
<x-filament::button tag="a" :href="$this->modeUrl('compact')" :color="$requestedMode === 'compact' ? 'primary' : 'gray'" size="sm">
Compact
</x-filament::button>
@else
<x-filament::badge color="gray" size="sm">
Compact unlocks at one visible tenant
</x-filament::badge>
@endif
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2" data-testid="baseline-compare-matrix-last-updated">
@if (($supportSurfaceState['showLastUpdated'] ?? true) && filled($lastUpdatedAt))
<x-filament::badge color="gray" icon="heroicon-m-clock" size="sm">
Last updated {{ \Illuminate\Support\Carbon::parse($lastUpdatedAt)->diffForHumans() }}
</x-filament::badge>
@endif
@if (($supportSurfaceState['showAutoRefreshHint'] ?? false) && $autoRefreshActive)
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Passive auto-refresh every 5 seconds
</x-filament::badge>
@endif
<div wire:loading.flex wire:target="refreshMatrix,applyFilters,resetFilters" class="items-center">
<x-filament::badge color="warning" icon="heroicon-m-arrow-path" size="sm">
Refreshing now
</x-filament::badge>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<x-filament::button type="button" wire:click="refreshMatrix" wire:loading.attr="disabled" wire:target="refreshMatrix,applyFilters,resetFilters" color="gray" size="sm">
Refresh matrix
</x-filament::button>
@if ($hiddenAssignedTenantCount > 0)
<span class="text-xs text-gray-500 dark:text-gray-400">
Visible-set only. Hidden tenants never contribute to summaries or drilldowns.
</span>
@endif
</div>
</div>
</div>
</div>
</div>
</x-filament::section>
<x-filament::section heading="Filters">
<x-slot name="description">
Heavy filters stage locally first. The matrix keeps rendering the applied scope until you explicitly apply or reset the draft.
</x-slot>
<div class="space-y-4" data-testid="baseline-compare-matrix-filters">
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-2 min-w-0">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Applied matrix scope</div>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
@if ($activeFilterCount === 0)
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are already shaping the rendered matrix.
@endif
</p>
@if ($activeFilterSummary !== [])
<div class="flex flex-wrap gap-2">
@foreach ($activeFilterSummary as $label => $value)
<x-filament::badge color="info" size="sm">
{{ $label }}: {{ $value }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
@if ($activeFilterCount === 0)
All visible results
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
@endif
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Tenant sort: {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Subject sort: {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
</x-filament::badge>
</div>
</div>
@if ($hasStagedFilterChanges)
<div class="mt-3 rounded-xl border border-primary-200 bg-primary-50/70 px-3 py-3 dark:border-primary-900/60 dark:bg-primary-950/20" data-testid="baseline-compare-matrix-staged-filters">
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
</p>
</div>
@if ($stagedFilterSummary !== [])
<div class="flex flex-wrap gap-2">
@foreach ($stagedFilterSummary as $label => $value)
<x-filament::badge color="primary" size="sm">
{{ $label }}: {{ is_string($value) ? \Illuminate\Support\Str::headline(str_replace('_', ' ', $value)) : $value }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
</div>
@endif
</div>
<form wire:submit.prevent="applyFilters" class="space-y-4">
{{ $this->form }}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
@if (filled($currentFilters['subject_key'] ?? null))
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
{{ $currentFilters['subject_key'] }}
</x-filament::badge>
<x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
Clear subject focus
</x-filament::button>
@else
<span class="text-gray-500 dark:text-gray-400">
None set yet. Use Focus subject from a row when you want a subject-first drilldown.
</span>
@endif
</div>
</div>
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Apply filters
</x-filament::button>
<x-filament::button type="button" wire:click="resetFilters" color="gray" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Reset filters
</x-filament::button>
</div>
</div>
</form>
<x-filament-actions::modals />
</div>
</x-filament::section>
<x-filament::section heading="Support context">
<x-slot name="description">
Status, legends, and refresh cues stay compact so the matrix body remains the primary working surface.
</x-slot>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current scope</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ $visibleTenantCount }} visible {{ \Illuminate\Support\Str::plural('tenant', $visibleTenantCount) }}.
{{ $resolvedMode === 'dense' ? 'State-first dense scan stays active.' : 'Compact single-tenant review stays active.' }}
</p>
@if ($policyTypeOptions !== [])
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ count($policyTypeOptions) }} searchable policy types
</x-filament::badge>
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="gray" size="sm">
Visible-set only
</x-filament::badge>
@endif
</div>
@endif
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Refresh honesty</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Manual refresh shows a blocking state only while you explicitly redraw. Background polling remains a passive hint.
</p>
@if ($autoRefreshActive)
<div class="mt-3">
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Compare work is still queued or running
</x-filament::badge>
</div>
@endif
</div>
</div>
<details class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm open:bg-white dark:border-gray-800 dark:bg-gray-900/50 dark:open:bg-gray-900/70">
<summary class="cursor-pointer list-none">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Grouped legend</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
State, freshness, and trust stay available on demand without pushing the matrix down the page.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">{{ count($stateLegend) }} states</x-filament::badge>
<x-filament::badge color="gray" size="sm">{{ count($freshnessLegend) }} freshness cues</x-filament::badge>
<x-filament::badge color="gray" size="sm">{{ count($trustLegend) }} trust cues</x-filament::badge>
</div>
</div>
</summary>
<div class="mt-4 grid gap-3">
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($stateLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($freshnessLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($trustLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
</div>
</details>
</div>
</x-filament::section>
<div class="relative" data-testid="baseline-compare-matrix-results">
@if ($emptyState !== null)
<x-filament::section heading="Results">
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-8 dark:border-gray-700 dark:bg-gray-900/40">
<div class="space-y-3" data-testid="baseline-compare-matrix-empty-state">
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] ?? 'Nothing to show' }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}</p>
@if ($activeFilterCount > 0)
<div class="pt-1">
<x-filament::button type="button" wire:click="resetFilters" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Reset filters
</x-filament::button>
</div>
@endif
</div>
</div>
</x-filament::section>
@elseif ($resolvedMode === 'compact')
@php
$compactTenant = $tenantSummaries[0] ?? null;
$compactTenantFreshnessSpec = $freshnessBadge($compactTenant['freshnessState'] ?? null);
$compactTenantTrustSpec = $trustBadge($compactTenant['trustLevel'] ?? null);
@endphp
<x-filament::section heading="Compact compare results">
<x-slot name="description">
One visible tenant remains in scope, so the matrix collapses into a shorter subject-result list instead of a pseudo-grid.
</x-slot>
@if ($compactTenant)
<div class="mb-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70" data-testid="baseline-compare-matrix-compact-shell">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $compactTenant['tenantName'] }}</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Compact mode stays visible-set only. Subject drilldowns and run links still preserve the matrix context.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$compactTenantFreshnessSpec->color" :icon="$compactTenantFreshnessSpec->icon" size="sm">
{{ $compactTenantFreshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$compactTenantTrustSpec->color" :icon="$compactTenantTrustSpec->icon" size="sm">
{{ $compactTenantTrustSpec->label }}
</x-filament::badge>
</div>
</div>
</div>
@endif
<div class="space-y-3">
@foreach ($compactResults as $result)
@php
$stateSpec = $stateBadge($result['state'] ?? null);
$freshnessSpec = $freshnessBadge($result['freshnessState'] ?? null);
$trustSpec = $trustBadge($result['trustLevel'] ?? null);
$severitySpec = filled($result['severity'] ?? null) ? $severityBadge($result['severity']) : null;
$tenantId = (int) ($result['tenantId'] ?? 0);
$subjectKey = $result['subjectKey'] ?? null;
$primaryUrl = filled($result['findingId'] ?? null)
? $this->findingUrl($tenantId, (int) $result['findingId'], $subjectKey)
: $this->tenantCompareUrl($tenantId, $subjectKey);
$runUrl = filled($result['compareRunId'] ?? null)
? $this->runUrl((int) $result['compareRunId'], $tenantId, $subjectKey)
: null;
$attentionClasses = match ((string) ($result['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
$attentionLabel = \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($result['attentionLevel'] ?? 'review')));
@endphp
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-base font-semibold text-gray-950 dark:text-white">
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $result['policyType'] ?? 'Unknown policy type' }}
</div>
@if (filled($result['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reference ID: {{ $result['baselineExternalId'] }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon" size="sm">
{{ $stateSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@if ($severitySpec)
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
{{ $attentionLabel }}
</span>
</div>
@if (filled($result['reasonSummary'] ?? null) || filled($result['lastComparedAt'] ?? null))
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
@if (filled($result['reasonSummary'] ?? null))
<div>{{ $result['reasonSummary'] }}</div>
@endif
@if (filled($result['lastComparedAt'] ?? null))
<div>Compared {{ \Illuminate\Support\Carbon::parse($result['lastComparedAt'])->diffForHumans() }}</div>
@endif
</div>
@endif
</div>
<div class="flex flex-col gap-3 xl:items-end">
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
Drift breadth {{ (int) ($result['deviationBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Missing {{ (int) ($result['missingBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Ambiguous {{ (int) ($result['ambiguousBreadth'] ?? 0) }}
</x-filament::badge>
</div>
<div class="flex flex-wrap gap-3 text-sm">
@if ($primaryUrl)
<x-filament::link :href="$primaryUrl" size="sm">
{{ filled($result['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
</x-filament::link>
@endif
@if ($runUrl)
<x-filament::link :href="$runUrl" color="gray" size="sm">
Open run
</x-filament::link>
@endif
@if (filled($result['subjectKey'] ?? null))
<x-filament::link :href="$this->filterUrl(['subject_key' => $result['subjectKey']])" color="gray" size="sm">
Focus subject
</x-filament::link>
@endif
</div>
</div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@else
<x-filament::section heading="Dense multi-tenant scan">
<x-slot name="description">
The matrix body is state-first. Row click stays forbidden, the subject column stays pinned, and repeated follow-up actions move behind compact secondary reveals.
</x-slot>
<div class="mb-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
@foreach ($tenantSummaries as $tenantSummary)
@php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
$trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null);
$tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null;
$tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']);
$tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null)
? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId'])
: null;
@endphp
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@if ($tenantSeveritySpec)
<x-filament::badge :color="$tenantSeveritySpec->color" :icon="$tenantSeveritySpec->icon" size="sm">
{{ $tenantSeveritySpec->label }}
</x-filament::badge>
@endif
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Aligned</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Drift</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Missing</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Ambiguous</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) }}</div>
</div>
</div>
<div class="flex flex-wrap gap-3 text-sm">
@if ($tenantCompareUrl)
<x-filament::link :href="$tenantCompareUrl" size="sm">
Open tenant compare
</x-filament::link>
@endif
@if ($tenantRunUrl)
<x-filament::link :href="$tenantRunUrl" color="gray" size="sm">
Open latest run
</x-filament::link>
@endif
</div>
</div>
</div>
@endforeach
</div>
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
<div class="min-w-[82rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800" data-testid="baseline-compare-matrix-dense-shell">
<table class="min-w-full border-separate border-spacing-0">
<thead class="bg-gray-50 dark:bg-gray-950/70">
<tr>
<th class="sticky left-0 z-20 w-[22rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
Baseline subject
</th>
@foreach ($tenantSummaries as $tenantSummary)
@php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
@endphp
<th class="min-w-[16rem] border-b border-gray-200 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
<div class="space-y-2">
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</div>
</div>
</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($denseRows as $row)
@php
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
$rowSurfaceClasses = $loop->even
? 'bg-gray-50/70 dark:bg-gray-950/20'
: 'bg-white dark:bg-gray-900/60';
$subjectAttentionClasses = match ((string) ($subject['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
@endphp
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}" data-testid="baseline-compare-matrix-row">
<td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $subject['policyType'] ?? 'Unknown policy type' }}
</div>
@if (filled($subject['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reference ID: {{ $subject['baselineExternalId'] }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Missing {{ (int) ($subject['missingBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge :color="$subjectTrustSpec->color" :icon="$subjectTrustSpec->icon" size="sm">
{{ $subjectTrustSpec->label }}
</x-filament::badge>
@if ($subjectSeveritySpec)
<x-filament::badge :color="$subjectSeveritySpec->color" :icon="$subjectSeveritySpec->icon" size="sm">
{{ $subjectSeveritySpec->label }}
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $subjectAttentionClasses }}">
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($subject['attentionLevel'] ?? 'review'))) }}
</span>
</div>
@if (filled($subject['subjectKey'] ?? null))
<div class="flex flex-wrap gap-3 text-sm">
<x-filament::link :href="$this->filterUrl(['subject_key' => $subject['subjectKey']])" color="gray" size="sm" data-testid="matrix-focus-subject">
Focus subject
</x-filament::link>
</div>
@endif
</div>
</td>
@foreach ($cells as $cell)
@php
$cellStateSpec = $stateBadge($cell['state'] ?? null);
$cellFreshnessSpec = $freshnessBadge($cell['freshnessState'] ?? null);
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
$tenantId = (int) ($cell['tenantId'] ?? 0);
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
$primaryUrl = filled($cell['findingId'] ?? null)
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
: $this->tenantCompareUrl($tenantId, $subjectKey);
$runUrl = filled($cell['compareRunId'] ?? null)
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
: null;
$attentionClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
$cellSurfaceClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
'needs_attention' => 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40',
'refresh_recommended' => 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40',
'aligned' => 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10',
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40',
};
@endphp
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
<div class="flex h-full flex-col gap-3 rounded-xl border p-3 text-xs transition-colors group-hover:border-primary-200 dark:group-hover:border-primary-900 {{ $cellSurfaceClasses }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
{{ $cellStateSpec->label }}
</x-filament::badge>
@if ($cellSeveritySpec)
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
{{ $cellSeveritySpec->label }}
</x-filament::badge>
@endif
</div>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($cell['attentionLevel'] ?? 'review'))) }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellFreshnessSpec->color" :icon="$cellFreshnessSpec->icon" size="sm">
{{ $cellFreshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
{{ $cellTrustSpec->label }}
</x-filament::badge>
</div>
@if (filled($cell['reasonSummary'] ?? null) || filled($cell['lastComparedAt'] ?? null))
<div class="space-y-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
@if (filled($cell['reasonSummary'] ?? null))
<div>{{ $cell['reasonSummary'] }}</div>
@endif
@if (filled($cell['lastComparedAt'] ?? null))
<div>Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}</div>
@endif
</div>
@endif
<div class="mt-auto space-y-2">
@if ($primaryUrl)
<div class="text-sm">
<x-filament::link :href="$primaryUrl" size="sm">
{{ filled($cell['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
</x-filament::link>
</div>
@endif
@if ($runUrl || filled($subjectKey))
<details class="rounded-lg border border-gray-200 bg-white/70 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-950/50">
<summary class="cursor-pointer list-none text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
More follow-up
</summary>
<div class="mt-2 flex flex-wrap gap-3 text-sm">
@if ($runUrl)
<x-filament::link :href="$runUrl" color="gray" size="sm">
Open run
</x-filament::link>
@endif
@if (filled($subjectKey))
<x-filament::link :href="$this->filterUrl(['subject_key' => $subjectKey])" color="gray" size="sm">
Focus subject
</x-filament::link>
@endif
</div>
</details>
@endif
</div>
</div>
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</x-filament::section>
@endif
</div>
</x-filament::page>

View File

@ -105,6 +105,7 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
@livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [ @livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [
'items' => $overview['attention_items'] ?? [], 'items' => $overview['attention_items'] ?? [],
'emptyState' => $overview['attention_empty_state'] ?? [], 'emptyState' => $overview['attention_empty_state'] ?? [],
'triageReviewProgress' => $overview['triage_review_progress'] ?? [],
], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none'))) ], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none')))
@livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [ @livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [

View File

@ -51,22 +51,43 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span> <span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span>
@if ($connection->display_name)
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $connection->display_name }}</span>
@endif
<x-filament::badge <x-filament::badge
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->color" :color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->color"
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->icon"
> >
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->label }} {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->label }}
</x-filament::badge> </x-filament::badge>
<x-filament::badge <x-filament::badge
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->color" :color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->color"
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->icon"
> >
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->label }} {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->label }}
</x-filament::badge>
<x-filament::badge
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->color"
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->icon"
>
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->label }}
</x-filament::badge> </x-filament::badge>
@if ($connection->is_default) @if ($connection->is_default)
<x-filament::badge color="info">Default</x-filament::badge> <x-filament::badge color="info">Default</x-filament::badge>
@endif @endif
</div> </div>
<div class="mt-2 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>Last check: {{ $connection->last_health_check_at?->diffForHumans() ?? 'Never' }}</span>
@if ($connection->last_error_reason_code)
<span>Last error: {{ $connection->last_error_reason_code }}</span>
@endif
</div>
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -1,5 +1,6 @@
@php @php
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */ /** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
/** @var array<string, mixed>|null $reviewState */
@endphp @endphp
<div> <div>
@ -11,6 +12,8 @@
'stale', 'degraded', 'weakened', 'unvalidated' => 'warning', 'stale', 'degraded', 'weakened', 'unvalidated' => 'warning',
default => 'gray', default => 'gray',
}; };
$reviewStateColor = \App\Support\Badges\BadgeRenderer::color(\App\Support\Badges\BadgeDomain::TenantTriageReviewState)($reviewState['derived_state'] ?? null);
$reviewStateLabel = \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::TenantTriageReviewState)($reviewState['derived_state'] ?? null);
@endphp @endphp
<x-filament::section> <x-filament::section>
@ -49,6 +52,24 @@ class="h-5 w-5 text-warning-500"
{{ $context->arrivalSummary }} {{ $context->arrivalSummary }}
</div> </div>
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
<div class="mt-3 flex flex-wrap items-center gap-2">
<x-filament::badge :color="$reviewStateColor" size="sm">
{{ $reviewStateLabel }}
</x-filament::badge>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $context->concernFamilyLabel() }} review state
@if (filled($reviewState['reviewed_by_user_name'] ?? null))
by {{ $reviewState['reviewed_by_user_name'] }}
@endif
@if (($reviewState['reviewed_at'] ?? null) instanceof \Illuminate\Support\Carbon)
· {{ $reviewState['reviewed_at']->diffForHumans() }}
@endif
</span>
</div>
@endif
@if (filled($context->currentTruthDelta)) @if (filled($context->currentTruthDelta))
<div class="mt-3 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-white/5 dark:text-gray-200"> <div class="mt-3 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-white/5 dark:text-gray-200">
{{ $context->currentTruthDelta }} {{ $context->currentTruthDelta }}
@ -89,9 +110,24 @@ class="h-5 w-5 text-warning-500"
{{ $context->nextStep['helperText'] }} {{ $context->nextStep['helperText'] }}
</div> </div>
@endif @endif
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
<div class="rounded-lg border border-gray-200 bg-white/80 p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
TenantPilot only. Review state tracks shared triage progress and never changes backup posture or recovery evidence.
</div>
@endif
</div> </div>
</div> </div>
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
<div class="flex flex-wrap items-center gap-2 border-t border-gray-200 pt-4 dark:border-white/10">
{{ $this->markReviewedAction }}
{{ $this->markFollowUpNeededAction }}
</div>
@endif
</div> </div>
</x-filament::section> </x-filament::section>
<x-filament-actions::modals />
@endif @endif
</div> </div>

View File

@ -1,4 +1,42 @@
<x-filament::section heading="Needs attention"> <x-filament::section heading="Needs attention">
@if ($triageReviewProgress !== [])
<div class="mb-4 grid grid-cols-1 gap-3 lg:grid-cols-2">
@foreach ($triageReviewProgress as $progress)
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $progress['label'] }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Reviewed {{ $progress['reviewed_count'] }}/{{ $progress['affected_total'] }} · Follow-up needed {{ $progress['follow_up_needed_count'] }} · Changed since review {{ $progress['changed_since_review_count'] }}
</div>
</div>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
Current affected set
</span>
</div>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<x-filament::link :href="$progress['not_reviewed_destination']['url']" size="sm">
Not reviewed {{ $progress['not_reviewed_count'] }}
</x-filament::link>
<x-filament::link :href="$progress['follow_up_needed_destination']['url']" size="sm">
Follow-up needed {{ $progress['follow_up_needed_count'] }}
</x-filament::link>
<x-filament::link :href="$progress['changed_since_review_destination']['url']" size="sm">
Changed since review {{ $progress['changed_since_review_count'] }}
</x-filament::link>
<x-filament::link :href="$progress['reviewed_destination']['url']" size="sm">
Reviewed {{ $progress['reviewed_count'] }}
</x-filament::link>
</div>
</div>
@endforeach
</div>
@endif
@if ($items === []) @if ($items === [])
<div class="flex h-full flex-col justify-between gap-4"> <div class="flex h-full flex-col justify-between gap-4">
<div class="space-y-2"> <div class="space-y-2">

View File

@ -39,7 +39,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser platform connection', 'display_name' => 'Browser platform connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$draft = createOnboardingDraft([ $draft = createOnboardingDraft([
@ -129,7 +129,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Polling verification connection', 'display_name' => 'Polling verification connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -233,7 +233,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Polling bootstrap connection', 'display_name' => 'Polling bootstrap connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$verificationRun = OperationRun::factory()->create([ $verificationRun = OperationRun::factory()->create([

View File

@ -40,7 +40,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Previously verified connection', 'display_name' => 'Previously verified connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([ $selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
@ -50,7 +50,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Current selected connection', 'display_name' => 'Current selected connection',
'is_default' => false, 'is_default' => false,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -142,7 +142,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Blocked review connection', 'display_name' => 'Blocked review connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$verificationRun = OperationRun::factory()->create([ $verificationRun = OperationRun::factory()->create([
@ -268,7 +268,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser assist connection', 'display_name' => 'Browser assist connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -405,7 +405,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser next-step connection', 'display_name' => 'Browser next-step connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -118,7 +118,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
TenantOnboardingSession::query()->create([ TenantOnboardingSession::query()->create([
@ -169,7 +169,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -238,7 +238,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Spec172 completed connection', 'display_name' => 'Spec172 completed connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$previousRun = OperationRun::factory()->create([ $previousRun = OperationRun::factory()->create([

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Models\Finding;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(BuildsBaselineCompareMatrixFixtures::class);
pest()->browser()->timeout(15_000);
it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => Finding::SEVERITY_CRITICAL],
);
$this->actingAs($fixture['user'])->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
$page = visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']));
$page
->assertNoJavaScriptErrors()
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
->assertSee('Dense multi-tenant scan')
->assertSee('Grouped legend')
->assertSee('Open finding')
->assertSee('More follow-up')
->click('Open finding')
->waitForText('Back to compare matrix')
->assertNoJavaScriptErrors()
->assertSee('Back to compare matrix');
});
it('smokes the compact single-tenant path when only one visible tenant remains', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => Finding::SEVERITY_HIGH],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$this->actingAs($viewer)->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertNoJavaScriptErrors()
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
->assertSee('Compact compare results')
->assertSee('Open finding');
});
it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
attributes: [
'status' => \App\Support\OperationRunStatus::Queued->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'completed_at' => null,
'started_at' => now(),
],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->actingAs($fixture['user'])->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
->assertNoJavaScriptErrors()
->waitForText('No rows match the current filters')
->assertSee('Passive auto-refresh every 5 seconds')
->click('Reset filters')
->waitForText('Dense multi-tenant scan')
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertNoJavaScriptErrors();
});

View File

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Models\AlertDestination;
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingExceptionService;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Workspaces\WorkspaceContext;
pest()->browser()->timeout(20_000);
function spec192ApprovedFindingException(Tenant $tenant, User $requester)
{
$approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
/** @var FindingExceptionService $service */
$service = app(FindingExceptionService::class);
$requested = $service->request($finding, $tenant, $requester, [
'owner_user_id' => (int) $requester->getKey(),
'request_reason' => 'Browser smoke test exception request.',
'review_due_at' => now()->addDays(7)->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
]);
return $service->approve($requested, $approver, [
'effective_from' => now()->subDay()->toDateTimeString(),
'expires_at' => now()->addDays(14)->toDateTimeString(),
'approval_reason' => 'Browser smoke approval.',
]);
}
it('smokes remediated standard record pages with contextual navigation and one clear next step', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Spec192 Browser Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$onboardingTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec192 Browser Onboarding Tenant',
]);
createUserWithTenant(
tenant: $onboardingTenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
createOnboardingDraft([
'workspace' => $onboardingTenant->workspace,
'tenant' => $onboardingTenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
'tenant_name' => (string) $onboardingTenant->name,
],
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec192 Browser Baseline',
]);
$baselineSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
\App\Models\BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'operation_run_id' => (int) $run->getKey(),
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now(),
]);
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$exception = spec192ApprovedFindingException($tenant, $user);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Review compare matrix')
->assertSee('Compare now');
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Review pack')
->assertSee('Refresh evidence');
visit(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Open finding')
->assertSee('Renew exception');
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Artifact truth')
->assertSee('Evidence snapshot');
visit(TenantResource::getUrl('edit', ['record' => $onboardingTenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Resume onboarding')
->assertSee('Open tenant detail');
});
it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Spec192 Workflow Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
->assertSee('Edit tenant')
->assertSee('Open provider connections');
});
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
$tenant = Tenant::factory()->active()->create([
'name' => 'Spec192 Reference Tenant',
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$workspace = $tenant->workspace;
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Spec192 Reference Baseline',
]);
$baselineSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Spec192 Browser Policy',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'version_number' => 4,
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $baselineSnapshot->getKey(),
'meta_jsonb' => [
'display_name' => 'Spec192 Browser Policy',
'version_reference' => [
'policy_version_id' => (int) $version->getKey(),
],
],
]);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Spec192 Browser Backup',
]);
OperationRun::factory()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies',
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$reviewSnapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $reviewSnapshot);
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $reviewSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$destination = AlertDestination::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec192 Browser Destination',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
->waitForText('Download')
->assertNoJavaScriptErrors()
->assertSee('Regenerate');
visit(AlertDestinationResource::getUrl('view', ['record' => $destination], panel: 'admin'))
->waitForText('Send test message')
->assertNoJavaScriptErrors()
->assertSee('Details');
visit(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertSee('View snapshot');
visit(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin'))
->waitForText('Memberships')
->assertNoJavaScriptErrors()
->assertSee('Edit');
visit(BaselineSnapshotResource::getUrl('view', ['record' => $baselineSnapshot], panel: 'admin'))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
->assertSee('Spec192 Browser Policy');
visit(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->waitForText('Related context')
->assertNoJavaScriptErrors()
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
->assertSee('Operations');
});

View File

@ -8,7 +8,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('stores successful admin consent on provider connection status', function () { it('stores successful admin consent on provider connection canonical state', function () {
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1', 'tenant_id' => 'tenant-1',
'name' => 'Contoso', 'name' => 'Contoso',
@ -32,7 +32,9 @@
->first(); ->first();
expect($connection)->not->toBeNull() expect($connection)->not->toBeNull()
->and($connection?->status)->toBe('connected') ->and($connection?->is_enabled)->toBeTrue()
->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('granted')
->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown')
->and($connection?->last_error_reason_code)->toBeNull(); ->and($connection?->last_error_reason_code)->toBeNull();
$this->assertDatabaseHas('audit_logs', [ $this->assertDatabaseHas('audit_logs', [
@ -81,11 +83,13 @@
->first(); ->first();
expect($connection)->not->toBeNull() expect($connection)->not->toBeNull()
->and($connection?->status)->toBe('needs_consent') ->and($connection?->is_enabled)->toBeTrue()
->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('required')
->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown')
->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConsentMissing); ->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConsentMissing);
}); });
it('records consent callback errors on provider connection state', function () { it('records consent callback errors on provider connection canonical state', function () {
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-2', 'tenant_id' => 'tenant-2',
'name' => 'Fabrikam', 'name' => 'Fabrikam',
@ -105,7 +109,9 @@
->first(); ->first();
expect($connection)->not->toBeNull() expect($connection)->not->toBeNull()
->and($connection?->status)->toBe('error') ->and($connection?->is_enabled)->toBeTrue()
->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('failed')
->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown')
->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed) ->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed)
->and($connection?->last_error_message)->toBe('access_denied'); ->and($connection?->last_error_message)->toBe('access_denied');

View File

@ -163,7 +163,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Audit Connection', 'display_name' => 'Audit Connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$draft = createOnboardingDraft([ $draft = createOnboardingDraft([

View File

@ -67,10 +67,9 @@ public function request(string $method, string $path, array $options = []): Grap
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => 'revoked-audit-tenant-id', 'entra_tenant_id' => 'revoked-audit-tenant-id',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => 'granted', 'consent_status' => 'granted',
'verification_status' => 'healthy', 'verification_status' => 'healthy',
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -105,7 +104,6 @@ public function request(string $method, string $path, array $options = []): Grap
expect($connection->consent_status?->value ?? $connection->consent_status)->toBe('revoked') expect($connection->consent_status?->value ?? $connection->consent_status)->toBe('revoked')
->and($connection->verification_status?->value ?? $connection->verification_status)->toBe('blocked') ->and($connection->verification_status?->value ?? $connection->verification_status)->toBe('blocked')
->and($connection->status)->toBe('needs_consent')
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentRevoked); ->and($run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentRevoked);
@ -115,6 +113,8 @@ public function request(string $method, string $path, array $options = []): Grap
->latest('id') ->latest('id')
->first(); ->first();
$metadata = is_array($log?->metadata ?? null) ? $log->metadata : [];
expect($log)->not->toBeNull() expect($log)->not->toBeNull()
->and($log?->status)->toBe('failed') ->and($log?->status)->toBe('failed')
->and($log?->resource_type)->toBe('provider_connection') ->and($log?->resource_type)->toBe('provider_connection')
@ -125,4 +125,7 @@ public function request(string $method, string $path, array $options = []): Grap
->and($log?->metadata['consent_status'] ?? null)->toBe('revoked') ->and($log?->metadata['consent_status'] ?? null)->toBe('revoked')
->and($log?->metadata['verification_status'] ?? null)->toBe('blocked') ->and($log?->metadata['verification_status'] ?? null)->toBe('blocked')
->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing); ->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing);
expect($metadata)->not->toHaveKey('status')
->and($metadata)->not->toHaveKey('health_status');
}); });

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