Compare commits
1 Commits
dev
...
188-provid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394025a2cd |
14
.github/agents/copilot-instructions.md
vendored
14
.github/agents/copilot-instructions.md
vendored
@ -163,14 +163,6 @@ ## Active Technologies
|
|||||||
- 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)
|
- 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)
|
- 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)
|
||||||
|
|
||||||
@ -205,8 +197,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
- 188-provider-connection-state-cleanup: Added 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
|
||||||
- 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
|
- 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
|
||||||
- 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
|
- 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
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -109,15 +109,6 @@ ### 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, 0–2 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 0–3 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.
|
||||||
|
|
||||||
|
|||||||
@ -1,236 +0,0 @@
|
|||||||
# 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 (0–2 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 |
|
|
||||||
|---|---|
|
|
||||||
| **10–12** | Freigabefähig |
|
|
||||||
| **7–9** | Nur freigeben wenn Scope enger gezogen wird |
|
|
||||||
| **4–6** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden |
|
|
||||||
| **0–3** | 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 |
|
|
||||||
@ -5,24 +5,6 @@ # 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]
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
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;
|
||||||
@ -110,13 +109,6 @@ 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();
|
||||||
@ -138,12 +130,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,9 +244,6 @@ 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,
|
||||||
@ -319,19 +302,9 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = [];
|
return [
|
||||||
$navigationContext = $this->navigationContext();
|
$this->compareNowAction(),
|
||||||
|
];
|
||||||
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
|
||||||
@ -416,7 +389,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, $this->navigationContext())),
|
->url(OperationRunLinks::view($run, $tenant)),
|
||||||
] : [])
|
] : [])
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
@ -463,15 +436,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,756 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -26,9 +25,6 @@
|
|||||||
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;
|
||||||
@ -47,7 +43,6 @@
|
|||||||
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;
|
||||||
@ -140,7 +135,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 keeps one state-sensitive primary action, moves snapshot and compare-matrix navigation into contextual related context, and groups secondary actions under "More".');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -323,15 +318,6 @@ 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')
|
||||||
@ -347,37 +333,6 @@ 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();
|
||||||
@ -492,16 +447,10 @@ 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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -16,13 +16,15 @@
|
|||||||
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;
|
||||||
@ -35,19 +37,24 @@ 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(),
|
||||||
ActionGroup::make([
|
|
||||||
$this->compareAssignedTenantsAction(),
|
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn (): bool => $this->hasManageCapability()),
|
->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 */
|
||||||
@ -69,7 +76,6 @@ 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)
|
||||||
@ -182,8 +188,6 @@ 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)
|
||||||
@ -303,71 +307,6 @@ 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>
|
||||||
*/
|
*/
|
||||||
@ -468,48 +407,4 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
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;
|
||||||
@ -19,7 +18,6 @@
|
|||||||
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;
|
||||||
@ -117,7 +115,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 as the primary action, keeps Expire snapshot visibly separated as danger, and renders operation/review-pack navigation in contextual related context.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -183,15 +181,6 @@ 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')
|
||||||
@ -224,48 +213,6 @@ 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
|
||||||
|
|||||||
@ -5,9 +5,12 @@
|
|||||||
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;
|
||||||
@ -26,11 +29,30 @@ 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();
|
||||||
@ -70,4 +92,11 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function latestReviewPack(): ?ReviewPack
|
||||||
|
{
|
||||||
|
return $this->record->reviewPacks()
|
||||||
|
->latest('created_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
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;
|
||||||
@ -21,7 +20,6 @@
|
|||||||
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;
|
||||||
@ -36,7 +34,6 @@
|
|||||||
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;
|
||||||
@ -118,7 +115,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 renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -220,15 +217,6 @@ 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')
|
||||||
@ -257,44 +245,6 @@ 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 tenant’s 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
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
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;
|
||||||
@ -33,10 +34,40 @@ 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('primary')
|
->color('warning')
|
||||||
->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,
|
||||||
|
|||||||
@ -1257,16 +1257,6 @@ 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(
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
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;
|
||||||
@ -24,17 +23,7 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = [];
|
return [
|
||||||
$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')
|
||||||
@ -64,16 +53,11 @@ 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -18,8 +18,13 @@ class EditTenant extends EditRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter([
|
return [
|
||||||
Actions\ActionGroup::make([
|
Actions\ViewAction::make(),
|
||||||
|
Actions\Action::make('related_onboarding')
|
||||||
|
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
||||||
|
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
|
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('restore')
|
Action::make('restore')
|
||||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||||
@ -56,16 +61,6 @@ protected function getHeaderActions(): array
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
];
|
||||||
->label('Lifecycle')
|
|
||||||
->icon('heroicon-o-archive-box')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
|
||||||
&& in_array(
|
|
||||||
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
|
||||||
['archive', 'restore'],
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, recovery evidence, or review-state filter, or return to the default calm-browsing order.';
|
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getTableEmptyStateDescription();
|
return parent::getTableEmptyStateDescription();
|
||||||
@ -85,7 +85,6 @@ 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) {
|
||||||
@ -94,10 +93,9 @@ 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', 'review_state', 'triage_sort'] as $filterName) {
|
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) {
|
||||||
data_forget($this->tableFilters, $filterName);
|
data_forget($this->tableFilters, $filterName);
|
||||||
data_forget($this->tableDeferredFilters, $filterName);
|
data_forget($this->tableDeferredFilters, $filterName);
|
||||||
}
|
}
|
||||||
@ -112,11 +110,6 @@ 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;
|
||||||
@ -129,19 +122,17 @@ 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>, review_state: list<string>, triage_sort: string|null}
|
* @return array{backup_posture: list<string>, recovery_evidence: 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')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -55,8 +56,29 @@ protected function getHeaderWidgets(): array
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter([
|
return [
|
||||||
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')
|
||||||
@ -69,13 +91,6 @@ 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())
|
||||||
@ -141,6 +156,10 @@ 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())
|
||||||
@ -264,13 +283,6 @@ 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')
|
||||||
@ -306,11 +318,9 @@ protected function getHeaderActions(): array
|
|||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Lifecycle')
|
->label('Actions')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
->color('gray')
|
->color('gray'),
|
||||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
];
|
||||||
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 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.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -570,7 +570,6 @@ 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)],
|
||||||
@ -580,43 +579,6 @@ 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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
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;
|
||||||
@ -52,105 +53,35 @@ protected function authorizeAccess(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$secondaryActions = $this->secondaryLifecycleActions();
|
return [
|
||||||
|
Actions\Action::make('view_run')
|
||||||
return array_values(array_filter([
|
->label('Open operation')
|
||||||
$this->primaryLifecycleAction(),
|
->icon('heroicon-o-eye')
|
||||||
Actions\ActionGroup::make($secondaryActions)
|
|
||||||
->label('More')
|
|
||||||
->icon('heroicon-m-ellipsis-vertical')
|
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $secondaryActions !== []),
|
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
||||||
Actions\ActionGroup::make([
|
->url(fn (): ?string => $this->record->operation_run_id
|
||||||
$this->archiveReviewAction(),
|
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
||||||
])
|
: null),
|
||||||
->label('Danger')
|
Actions\Action::make('view_export')
|
||||||
->icon('heroicon-o-archive-box')
|
->label('View executive pack')
|
||||||
->color('danger')
|
->icon('heroicon-o-document-arrow-down')
|
||||||
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
->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)
|
||||||
private function primaryLifecycleAction(): ?Actions\Action
|
: null),
|
||||||
{
|
Actions\Action::make('view_evidence')
|
||||||
return match ($this->primaryLifecycleActionName()) {
|
->label('View evidence snapshot')
|
||||||
'refresh_review' => $this->refreshReviewAction(),
|
->icon('heroicon-o-shield-check')
|
||||||
'publish_review' => $this->publishReviewAction(),
|
->color('gray')
|
||||||
'export_executive_pack' => $this->exportExecutivePackAction(),
|
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
||||||
default => null,
|
->url(fn (): ?string => $this->record->evidenceSnapshot
|
||||||
};
|
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
||||||
}
|
: null),
|
||||||
|
UiEnforcement::forAction(
|
||||||
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')
|
Actions\Action::make('refresh_review')
|
||||||
->label('Refresh review')
|
->label('Refresh review')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
@ -173,16 +104,11 @@ private function refreshReviewAction(): Actions\Action
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply(),
|
||||||
}
|
UiEnforcement::forAction(
|
||||||
|
|
||||||
private function publishReviewAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('publish_review')
|
Actions\Action::make('publish_review')
|
||||||
->label('Publish review')
|
->label('Publish review')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
@ -206,17 +132,12 @@ private function publishReviewAction(): Actions\Action
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply(),
|
||||||
}
|
UiEnforcement::forAction(
|
||||||
|
|
||||||
private function exportExecutivePackAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->color('primary')
|
->hidden(fn (): bool => ! in_array($this->record->status, [
|
||||||
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
TenantReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
TenantReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
@ -224,12 +145,9 @@ private function exportExecutivePackAction(): Actions\Action
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply(),
|
||||||
}
|
Actions\ActionGroup::make([
|
||||||
|
UiEnforcement::forAction(
|
||||||
private function createNextReviewAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_next_review')
|
Actions\Action::make('create_next_review')
|
||||||
->label('Create next review')
|
->label('Create next review')
|
||||||
->icon('heroicon-o-document-duplicate')
|
->icon('heroicon-o-document-duplicate')
|
||||||
@ -254,12 +172,8 @@ private function createNextReviewAction(): Actions\Action
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply(),
|
||||||
}
|
UiEnforcement::forAction(
|
||||||
|
|
||||||
private function archiveReviewAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive_review')
|
Actions\Action::make('archive_review')
|
||||||
->label('Archive review')
|
->label('Archive review')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-archive-box')
|
||||||
@ -281,6 +195,11 @@ private function archiveReviewAction(): Actions\Action
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-m-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,51 +5,18 @@
|
|||||||
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 implements HasActions, HasSchemas
|
class TenantTriageArrivalContinuity extends Widget
|
||||||
{
|
{
|
||||||
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>
|
||||||
*/
|
*/
|
||||||
@ -58,210 +25,11 @@ protected function getViewData(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return ['context' => null, 'reviewState' => null];
|
return ['context' => null];
|
||||||
}
|
|
||||||
|
|
||||||
$context = $this->resolveArrivalContext($tenant);
|
|
||||||
|
|
||||||
if ($context === null) {
|
|
||||||
return ['context' => null, 'reviewState' => null];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'context' => $context,
|
'context' => app(PortfolioArrivalContextResolver::class)->resolve(request(), $tenant),
|
||||||
'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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,11 +46,6 @@ 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,
|
||||||
@ -76,12 +71,10 @@ 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 = [], array $triageReviewProgress = []): void
|
public function mount(array $items = [], array $emptyState = []): void
|
||||||
{
|
{
|
||||||
$this->items = $items;
|
$this->items = $items;
|
||||||
$this->emptyState = $emptyState;
|
$this->emptyState = $emptyState;
|
||||||
$this->triageReviewProgress = $triageReviewProgress;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -38,30 +37,4 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -274,18 +274,6 @@ 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
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
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;
|
||||||
@ -90,17 +89,6 @@ 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);
|
||||||
@ -296,34 +284,6 @@ 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) {
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -79,8 +79,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->app->scoped(CapabilityResolver::class);
|
$this->app->singleton(CapabilityResolver::class);
|
||||||
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,6 @@
|
|||||||
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;
|
||||||
@ -63,7 +62,6 @@ 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);
|
||||||
@ -184,7 +182,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling(null)
|
->databaseNotificationsPolling('30s')
|
||||||
->unsavedChangesAlerts()
|
->unsavedChangesAlerts()
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
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;
|
||||||
@ -32,12 +31,11 @@ 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(null)
|
->databaseNotificationsPolling('30s')
|
||||||
->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(),
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
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;
|
||||||
@ -41,7 +40,6 @@ 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()))
|
||||||
@ -95,7 +93,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling(null)
|
->databaseNotificationsPolling('30s')
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
|
|||||||
@ -54,7 +54,6 @@ 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,
|
||||||
],
|
],
|
||||||
@ -95,7 +94,6 @@ 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,
|
||||||
],
|
],
|
||||||
@ -123,7 +121,6 @@ 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,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
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;
|
||||||
@ -29,7 +28,6 @@ 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,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,34 +47,12 @@ public function startCompare(
|
|||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = $assignment->baselineProfile;
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
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) {
|
||||||
@ -148,103 +124,6 @@ public function startCompareForProfile(
|
|||||||
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) {
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -95,8 +95,6 @@ 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';
|
||||||
@ -230,8 +228,6 @@ 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',
|
||||||
|
|||||||
@ -143,9 +143,6 @@ 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';
|
||||||
|
|
||||||
|
|||||||
@ -62,13 +62,9 @@ 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,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -53,11 +53,7 @@ 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';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -245,57 +245,6 @@ 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>>
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -19,36 +18,6 @@ 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
@ -12,28 +12,6 @@ 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();
|
||||||
@ -398,6 +376,12 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::isStaleComparedAt($stats->lastComparedIso);
|
try {
|
||||||
|
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -66,31 +63,4 @@ 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 !== ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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->matchesRequestScope($tenant, $request, $state)) {
|
if ($state === null || ! $this->matchesScope($tenant, $request, $state)) {
|
||||||
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
|
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -61,26 +61,6 @@ 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,
|
||||||
@ -92,30 +72,7 @@ public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalCo
|
|||||||
* returnFilters: array<string, mixed>|null
|
* returnFilters: array<string, mixed>|null
|
||||||
* } $state
|
* } $state
|
||||||
*/
|
*/
|
||||||
private function matchesRequestScope(Tenant $tenant, Request $request, array $state): bool
|
private function matchesScope(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'];
|
||||||
|
|
||||||
@ -135,7 +92,7 @@ private function matchesTenantScope(Tenant $tenant, array $state): bool
|
|||||||
$workspaceId = $state['workspaceId'];
|
$workspaceId = $state['workspaceId'];
|
||||||
|
|
||||||
return $workspaceId === null
|
return $workspaceId === null
|
||||||
|| (int) $tenant->workspace_id === $workspaceId;
|
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -55,7 +55,6 @@ 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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,157 +0,0 @@
|
|||||||
<?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,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -660,22 +660,6 @@ 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
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,10 +39,6 @@ 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;
|
||||||
@ -63,14 +58,6 @@ 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;
|
||||||
@ -108,20 +95,6 @@ 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();
|
||||||
@ -138,22 +111,10 @@ private function applyVisibility(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingVisibility = $this->preserveExistingVisibility
|
$this->action->visible(function (?Model $record = null): bool {
|
||||||
? $this->getExistingVisibilityCondition()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
|
|
||||||
$context = $this->resolveContextWithRecord($record);
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
if (! $context->isMember) {
|
return $context->isMember;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existingVisibility === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,15 +126,7 @@ private function applyDisabledState(): void
|
|||||||
|
|
||||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||||
|
|
||||||
$existingDisabled = $this->preserveExistingDisabled
|
$this->action->disabled(function (?Model $record = null): bool {
|
||||||
? $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) {
|
||||||
@ -220,96 +173,6 @@ 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();
|
||||||
|
|||||||
@ -6,12 +6,10 @@
|
|||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@ -102,10 +100,7 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
|
|||||||
{
|
{
|
||||||
$scopes = [ActionSurfacePanelScope::Tenant];
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
||||||
|
|
||||||
if (
|
if (in_array($className, $adminScopedClasses, true)) {
|
||||||
in_array($className, $adminScopedClasses, true)
|
|
||||||
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
|
|
||||||
) {
|
|
||||||
$scopes[] = ActionSurfacePanelScope::Admin;
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,37 +228,6 @@ 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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,20 +4,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -63,275 +49,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,8 +54,6 @@ 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(
|
||||||
@ -108,128 +106,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
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;
|
||||||
@ -32,7 +31,6 @@
|
|||||||
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;
|
||||||
@ -49,7 +47,6 @@ public function __construct(
|
|||||||
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
||||||
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
||||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
private RestoreSafetyResolver $restoreSafetyResolver,
|
||||||
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,7 +66,6 @@ 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(
|
||||||
@ -145,7 +141,6 @@ 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,
|
||||||
);
|
);
|
||||||
@ -169,7 +164,6 @@ 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,
|
||||||
@ -834,7 +828,6 @@ 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 {
|
||||||
@ -868,11 +861,7 @@ private function summaryMetrics(
|
|||||||
label: 'Backup attention',
|
label: 'Backup attention',
|
||||||
value: $backupAttentionTenantCount,
|
value: $backupAttentionTenantCount,
|
||||||
category: 'backup_health',
|
category: 'backup_health',
|
||||||
description: $this->reviewSummaryMetricDescription(
|
description: 'Visible tenants with non-healthy backup posture.',
|
||||||
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,
|
||||||
@ -885,11 +874,7 @@ private function summaryMetrics(
|
|||||||
label: 'Recovery attention',
|
label: 'Recovery attention',
|
||||||
value: $recoveryAttentionTenantCount,
|
value: $recoveryAttentionTenantCount,
|
||||||
category: 'recovery_evidence',
|
category: 'recovery_evidence',
|
||||||
description: $this->reviewSummaryMetricDescription(
|
description: 'Visible tenants with weakened or unvalidated recovery evidence.',
|
||||||
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,
|
||||||
@ -927,83 +912,6 @@ 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>
|
||||||
*/
|
*/
|
||||||
@ -1030,77 +938,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -10,7 +10,6 @@
|
|||||||
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()) {
|
||||||
@ -20,258 +19,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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]';
|
||||||
@ -311,9 +58,6 @@
|
|||||||
'errors',
|
'errors',
|
||||||
'reason',
|
'reason',
|
||||||
'code',
|
'code',
|
||||||
'url',
|
|
||||||
'requestUrl',
|
|
||||||
'method',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of allowedKeys) {
|
for (const key of allowedKeys) {
|
||||||
@ -351,41 +95,23 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
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(),
|
||||||
requestUrl: transport?.requestUrl ?? null,
|
reason: normalizeReason(event.reason),
|
||||||
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(dedupeKey)) {
|
if (recentKeys.has(payloadJson)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recentKeys.set(dedupeKey, nowMs);
|
recentKeys.set(payloadJson, nowMs);
|
||||||
|
|
||||||
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
$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
|
||||||
@ -73,37 +72,6 @@
|
|||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,6 @@
|
|||||||
$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'])
|
||||||
@ -28,28 +26,6 @@
|
|||||||
};
|
};
|
||||||
@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">
|
||||||
|
|||||||
@ -1,868 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -105,7 +105,6 @@ 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, [
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
@php
|
@php
|
||||||
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
|
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
|
||||||
/** @var array<string, mixed>|null $reviewState */
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -12,8 +11,6 @@
|
|||||||
'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>
|
||||||
@ -52,24 +49,6 @@ 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 }}
|
||||||
@ -110,24 +89,9 @@ 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>
|
||||||
|
|||||||
@ -1,42 +1,4 @@
|
|||||||
<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">
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
<?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();
|
|
||||||
});
|
|
||||||
@ -1,300 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
@ -1,317 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$visibleRunTwo = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$hiddenRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['hiddenTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$visibleRunTwo,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['hiddenTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$hiddenRun,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
$wifiRow = collect($matrix['denseRows'])->first(
|
|
||||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
|
||||||
->and($matrix['reference']['visibleTenantCount'])->toBe(2)
|
|
||||||
->and(collect($matrix['tenantSummaries'])->pluck('tenantName')->all())->toEqualCanonicalizing([
|
|
||||||
(string) $fixture['visibleTenant']->name,
|
|
||||||
(string) $fixture['visibleTenantTwo']->name,
|
|
||||||
])
|
|
||||||
->and($wifiRow)->not->toBeNull()
|
|
||||||
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
|
|
||||||
->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention')
|
|
||||||
->and(count($wifiRow['cells']))->toBe(2)
|
|
||||||
->and($matrix['denseRows'])->toHaveCount(2)
|
|
||||||
->and($matrix['compactResults'])->toBeEmpty()
|
|
||||||
->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped')
|
|
||||||
->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse()
|
|
||||||
->and($matrix['lastUpdatedAt'])->not->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$matchTenant = $fixture['visibleTenant'];
|
|
||||||
$differTenant = $fixture['visibleTenantTwo'];
|
|
||||||
|
|
||||||
$missingTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Missing',
|
|
||||||
]);
|
|
||||||
$ambiguousTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Ambiguous',
|
|
||||||
]);
|
|
||||||
$notComparedTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Uncovered',
|
|
||||||
]);
|
|
||||||
$staleTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Contoso Stale',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $missingTenant->getKey() => ['role' => 'owner'],
|
|
||||||
(int) $ambiguousTenant->getKey() => ['role' => 'owner'],
|
|
||||||
(int) $notComparedTenant->getKey() => ['role' => 'owner'],
|
|
||||||
(int) $staleTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $missingTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $ambiguousTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $notComparedTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $staleTenant);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun($matchTenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
|
|
||||||
$differRun = $this->makeBaselineCompareMatrixRun($differTenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
$this->makeBaselineCompareMatrixFinding($differTenant, $fixture['profile'], $differRun, 'wifi-corp-profile', [
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
'change_type' => 'different_version',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$missingRun = $this->makeBaselineCompareMatrixRun($missingTenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
$this->makeBaselineCompareMatrixFinding($missingTenant, $fixture['profile'], $missingRun, 'wifi-corp-profile', [
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
'change_type' => 'missing_policy',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$ambiguousRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$ambiguousTenant,
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[
|
|
||||||
'baseline_compare' => [
|
|
||||||
'evidence_gaps' => [
|
|
||||||
'count' => 1,
|
|
||||||
'by_reason' => ['ambiguous_match' => 1],
|
|
||||||
'subjects' => [
|
|
||||||
$this->baselineCompareMatrixGap('deviceConfiguration', 'wifi-corp-profile', [
|
|
||||||
'reason_code' => 'ambiguous_match',
|
|
||||||
'resolution_outcome' => 'ambiguous_match',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$this->makeBaselineCompareMatrixFinding($ambiguousTenant, $fixture['profile'], $ambiguousRun, 'wifi-corp-profile', [
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
'change_type' => 'missing_policy',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$notComparedTenant,
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[
|
|
||||||
'baseline_compare' => [
|
|
||||||
'coverage' => [
|
|
||||||
'proof' => true,
|
|
||||||
'effective_types' => ['deviceConfiguration'],
|
|
||||||
'covered_types' => [],
|
|
||||||
'uncovered_types' => ['deviceConfiguration'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$staleTenant,
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
'completed_at' => $fixture['snapshot']->captured_at->copy()->subDay(),
|
|
||||||
'context' => [
|
|
||||||
'baseline_profile_id' => (int) $fixture['profile']->getKey(),
|
|
||||||
'baseline_snapshot_id' => (int) $fixture['snapshot']->getKey() - 1,
|
|
||||||
'baseline_compare' => [
|
|
||||||
'coverage' => [
|
|
||||||
'proof' => true,
|
|
||||||
'effective_types' => ['deviceConfiguration'],
|
|
||||||
'covered_types' => ['deviceConfiguration'],
|
|
||||||
'uncovered_types' => [],
|
|
||||||
],
|
|
||||||
'evidence_gaps' => [
|
|
||||||
'count' => 0,
|
|
||||||
'by_reason' => [],
|
|
||||||
'subjects' => [],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
$wifiRow = collect($matrix['denseRows'])->first(
|
|
||||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$cellsByTenant = collect($wifiRow['cells'] ?? [])
|
|
||||||
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match')
|
|
||||||
->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned')
|
|
||||||
->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ')
|
|
||||||
->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
|
||||||
->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.')
|
|
||||||
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
|
|
||||||
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
|
||||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
|
|
||||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
|
|
||||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
|
|
||||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
|
|
||||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
|
|
||||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
[],
|
|
||||||
[
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$visibleRun,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$deviceOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
|
||||||
'policyTypes' => ['deviceConfiguration'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$driftOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
|
||||||
'states' => ['differ'],
|
|
||||||
'severities' => ['critical'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$subjectFocus = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
|
|
||||||
'focusedSubjectKey' => 'wifi-corp-profile',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(count($deviceOnly['denseRows']))->toBe(1)
|
|
||||||
->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
|
||||||
->and(count($driftOnly['denseRows']))->toBe(1)
|
|
||||||
->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
|
||||||
->and(count($subjectFocus['denseRows']))->toBe(1)
|
|
||||||
->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$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'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer);
|
|
||||||
|
|
||||||
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
|
||||||
->and($matrix['reference']['visibleTenantCount'])->toBe(1)
|
|
||||||
->and($matrix['compactResults'])->toHaveCount(2)
|
|
||||||
->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()])
|
|
||||||
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ')
|
|
||||||
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention');
|
|
||||||
});
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('fans out compare starts across the visible assigned set without creating a workspace umbrella run', function (): void {
|
|
||||||
Queue::fake();
|
|
||||||
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$readonlyTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Readonly Contoso',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $readonlyTenant->getKey() => ['role' => 'readonly'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $readonlyTenant);
|
|
||||||
|
|
||||||
$service = app(BaselineCompareService::class);
|
|
||||||
|
|
||||||
$existingRunResult = $service->startCompareForProfile(
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['user'],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($existingRunResult['ok'] ?? false)->toBeTrue();
|
|
||||||
|
|
||||||
$result = $service->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
expect($result['visibleAssignedTenantCount'])->toBe(3)
|
|
||||||
->and($result['queuedCount'])->toBe(1)
|
|
||||||
->and($result['alreadyQueuedCount'])->toBe(1)
|
|
||||||
->and($result['blockedCount'])->toBe(1);
|
|
||||||
|
|
||||||
$launchStates = collect($result['targets'])
|
|
||||||
->mapWithKeys(static fn (array $target): array => [(int) $target['tenantId'] => (string) $target['launchState']])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($launchStates[(int) $fixture['visibleTenant']->getKey()] ?? null)->toBe('queued')
|
|
||||||
->and($launchStates[(int) $fixture['visibleTenantTwo']->getKey()] ?? null)->toBe('already_queued')
|
|
||||||
->and($launchStates[(int) $readonlyTenant->getKey()] ?? null)->toBe('blocked');
|
|
||||||
|
|
||||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
|
||||||
|
|
||||||
$activeRuns = OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
expect($activeRuns)->toHaveCount(2)
|
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
|
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
|
|
||||||
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
|
|
||||||
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
|
|
||||||
Queue::fake();
|
|
||||||
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
Livewire::actingAs($fixture['user'])
|
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
|
||||||
->assertActionEnabled('compareAssignedTenants')
|
|
||||||
->callAction('compareAssignedTenants')
|
|
||||||
->assertStatus(200);
|
|
||||||
|
|
||||||
Queue::assertPushed(CompareBaselineToTenantJob::class, 2);
|
|
||||||
|
|
||||||
expect(OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
|
||||||
->where('type', 'baseline_compare')
|
|
||||||
->whereNull('tenant_id')
|
|
||||||
->count())->toBe(0);
|
|
||||||
});
|
|
||||||
@ -12,13 +12,8 @@
|
|||||||
use App\Services\Drift\DriftHasher;
|
use App\Services\Drift\DriftHasher;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
|
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
|
||||||
bindFailHardGraphClient();
|
bindFailHardGraphClient();
|
||||||
@ -105,28 +100,3 @@
|
|||||||
expect($code)->toBeString();
|
expect($code)->toBeString();
|
||||||
expect($code)->toContain('->chunk(');
|
expect($code)->toContain('->chunk(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps matrix aggregation query-bounded over the visible assigned set', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
foreach (range(1, 6) as $index) {
|
|
||||||
$tenant = \App\Models\Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'name' => 'Matrix Tenant '.$index,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$fixture['user']->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $tenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($fixture['profile'], $tenant);
|
|
||||||
$this->makeBaselineCompareMatrixRun($tenant, $fixture['profile'], $fixture['snapshot']);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::enableQueryLog();
|
|
||||||
DB::flushQueryLog();
|
|
||||||
|
|
||||||
app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
|
||||||
|
|
||||||
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(20);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,272 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Concerns;
|
|
||||||
|
|
||||||
use App\Models\BaselineProfile;
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineSnapshotItem;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
|
|
||||||
|
|
||||||
trait BuildsBaselineCompareMatrixFixtures
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* user: User,
|
|
||||||
* workspace: Workspace,
|
|
||||||
* profile: BaselineProfile,
|
|
||||||
* snapshot: BaselineSnapshot,
|
|
||||||
* visibleTenant: Tenant,
|
|
||||||
* visibleTenantTwo: Tenant,
|
|
||||||
* hiddenTenant: Tenant,
|
|
||||||
* subjects: array<string, BaselineSnapshotItem>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
protected function makeBaselineCompareMatrixFixture(
|
|
||||||
string $viewerRole = 'owner',
|
|
||||||
?string $workspaceRole = null,
|
|
||||||
): array {
|
|
||||||
[$user, $visibleTenant] = createUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole);
|
|
||||||
|
|
||||||
$workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id);
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => BaselineProfileStatus::Active->value,
|
|
||||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
|
||||||
'name' => 'Visible-set baseline',
|
|
||||||
'scope_jsonb' => [
|
|
||||||
'policy_types' => ['deviceConfiguration', 'compliancePolicy'],
|
|
||||||
'foundation_types' => [],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'captured_at' => now()->subHours(2),
|
|
||||||
'completed_at' => now()->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->forceFill([
|
|
||||||
'active_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$visibleTenantTwo = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Northwind',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Hidden Fabrikam',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $visibleTenantTwo->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
WorkspaceMembership::query()->updateOrCreate([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
], [
|
|
||||||
'role' => $workspaceRole ?? $viewerRole,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assignTenantToBaselineProfile($profile, $visibleTenant);
|
|
||||||
$this->assignTenantToBaselineProfile($profile, $visibleTenantTwo);
|
|
||||||
$this->assignTenantToBaselineProfile($profile, $hiddenTenant);
|
|
||||||
|
|
||||||
$subjects = [
|
|
||||||
'wifi-corp-profile' => $this->makeBaselineCompareMatrixSubject(
|
|
||||||
$snapshot,
|
|
||||||
'deviceConfiguration',
|
|
||||||
'wifi-corp-profile',
|
|
||||||
'WiFi Corp Profile',
|
|
||||||
'dc:wifi-corp-profile',
|
|
||||||
),
|
|
||||||
'windows-compliance' => $this->makeBaselineCompareMatrixSubject(
|
|
||||||
$snapshot,
|
|
||||||
'compliancePolicy',
|
|
||||||
'windows-compliance',
|
|
||||||
'Windows Compliance',
|
|
||||||
'cp:windows-compliance',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'user' => $user,
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'profile' => $profile,
|
|
||||||
'snapshot' => $snapshot,
|
|
||||||
'visibleTenant' => $visibleTenant,
|
|
||||||
'visibleTenantTwo' => $visibleTenantTwo,
|
|
||||||
'hiddenTenant' => $hiddenTenant,
|
|
||||||
'subjects' => $subjects,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function makeBaselineCompareMatrixSubject(
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
string $policyType,
|
|
||||||
string $subjectKey,
|
|
||||||
string $displayName,
|
|
||||||
?string $subjectExternalId = null,
|
|
||||||
): BaselineSnapshotItem {
|
|
||||||
return BaselineSnapshotItem::factory()->create([
|
|
||||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
'subject_external_id' => $subjectExternalId ?? $policyType.':'.$subjectKey,
|
|
||||||
'meta_jsonb' => ['display_name' => $displayName],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function assignTenantToBaselineProfile(BaselineProfile $profile, Tenant $tenant): BaselineTenantAssignment
|
|
||||||
{
|
|
||||||
return BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $profile->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $contextOverrides
|
|
||||||
* @param array<string, mixed> $attributes
|
|
||||||
*/
|
|
||||||
protected function makeBaselineCompareMatrixRun(
|
|
||||||
Tenant $tenant,
|
|
||||||
BaselineProfile $profile,
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
array $contextOverrides = [],
|
|
||||||
array $attributes = [],
|
|
||||||
): OperationRun {
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'type' => OperationRunType::BaselineCompare->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'initiator_name' => 'Spec190 Matrix',
|
|
||||||
'summary_counts' => [
|
|
||||||
'matched_items' => 1,
|
|
||||||
'different_items' => 0,
|
|
||||||
'missing_items' => 0,
|
|
||||||
'unexpected_items' => 0,
|
|
||||||
],
|
|
||||||
'context' => array_replace_recursive([
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'baseline_compare' => [
|
|
||||||
'reason_code' => null,
|
|
||||||
'subjects_total' => 2,
|
|
||||||
'fidelity' => 'content',
|
|
||||||
'coverage' => [
|
|
||||||
'proof' => true,
|
|
||||||
'effective_types' => ['deviceConfiguration', 'compliancePolicy'],
|
|
||||||
'covered_types' => ['deviceConfiguration', 'compliancePolicy'],
|
|
||||||
'uncovered_types' => [],
|
|
||||||
],
|
|
||||||
'evidence_gaps' => [
|
|
||||||
'count' => 0,
|
|
||||||
'by_reason' => [],
|
|
||||||
'subjects' => [],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $contextOverrides),
|
|
||||||
'started_at' => now()->subMinutes(5),
|
|
||||||
'completed_at' => now()->subMinute(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return OperationRun::factory()->create(array_replace_recursive($defaults, $attributes));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $overrides
|
|
||||||
*/
|
|
||||||
protected function makeBaselineCompareMatrixFinding(
|
|
||||||
Tenant $tenant,
|
|
||||||
BaselineProfile $profile,
|
|
||||||
OperationRun $run,
|
|
||||||
string $subjectKey,
|
|
||||||
array $overrides = [],
|
|
||||||
): Finding {
|
|
||||||
$defaults = [
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
'source' => 'baseline.compare',
|
|
||||||
'scope_key' => 'baseline_profile:'.(int) $profile->getKey(),
|
|
||||||
'baseline_operation_run_id' => (int) $run->getKey(),
|
|
||||||
'current_operation_run_id' => (int) $run->getKey(),
|
|
||||||
'subject_type' => 'policy',
|
|
||||||
'subject_external_id' => 'subject:'.$subjectKey,
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'evidence_jsonb' => [
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
'change_type' => 'different_version',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
return Finding::factory()->create(array_replace_recursive($defaults, $overrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $overrides
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function baselineCompareMatrixGap(string $policyType, string $subjectKey, array $overrides = []): array
|
|
||||||
{
|
|
||||||
return BaselineSubjectResolutionFixtures::structuredGap(array_replace([
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'subject_key' => $subjectKey,
|
|
||||||
], $overrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setAdminWorkspaceContext(User $user, Workspace|int $workspace, ?Tenant $rememberedTenant = null): array
|
|
||||||
{
|
|
||||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
|
||||||
|
|
||||||
$session = [
|
|
||||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($rememberedTenant instanceof Tenant) {
|
|
||||||
$session[WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY] = [
|
|
||||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->actingAs($user)->withSession($session);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
|
||||||
|
|
||||||
if ($rememberedTenant instanceof Tenant) {
|
|
||||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
||||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Filament::setCurrentPanel('admin');
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
|
|
||||||
return $session;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,17 +8,12 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\PortfolioTriage\TenantTriageReviewService;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
||||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use InvalidArgumentException;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
trait BuildsPortfolioTriageFixtures
|
trait BuildsPortfolioTriageFixtures
|
||||||
@ -159,58 +154,12 @@ protected function portfolioTriageRegistryList(User $user, Tenant $workspaceTena
|
|||||||
protected function portfolioReturnFilters(
|
protected function portfolioReturnFilters(
|
||||||
array $backupPosture = [],
|
array $backupPosture = [],
|
||||||
array $recoveryEvidence = [],
|
array $recoveryEvidence = [],
|
||||||
array $reviewState = [],
|
|
||||||
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||||
): array {
|
): array {
|
||||||
return [
|
return [
|
||||||
'backup_posture' => $backupPosture,
|
'backup_posture' => $backupPosture,
|
||||||
'recovery_evidence' => $recoveryEvidence,
|
'recovery_evidence' => $recoveryEvidence,
|
||||||
'review_state' => $reviewState,
|
|
||||||
'triage_sort' => $triageSort,
|
'triage_sort' => $triageSort,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function seedPortfolioTriageReview(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $concernFamily,
|
|
||||||
string $manualState = TenantTriageReview::STATE_REVIEWED,
|
|
||||||
?User $actor = null,
|
|
||||||
bool $changedFingerprint = false,
|
|
||||||
): TenantTriageReview {
|
|
||||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
|
||||||
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
|
||||||
|
|
||||||
$review = match ($manualState) {
|
|
||||||
TenantTriageReview::STATE_REVIEWED => app(TenantTriageReviewService::class)->markReviewed(
|
|
||||||
tenant: $tenant,
|
|
||||||
concernFamily: $concernFamily,
|
|
||||||
backupHealth: $backupHealth,
|
|
||||||
recoveryEvidence: $recoveryEvidence,
|
|
||||||
actor: $actor,
|
|
||||||
),
|
|
||||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => app(TenantTriageReviewService::class)->markFollowUpNeeded(
|
|
||||||
tenant: $tenant,
|
|
||||||
concernFamily: $concernFamily,
|
|
||||||
backupHealth: $backupHealth,
|
|
||||||
recoveryEvidence: $recoveryEvidence,
|
|
||||||
actor: $actor,
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException('Unsupported triage review state.'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($changedFingerprint) {
|
|
||||||
$review->forceFill([
|
|
||||||
'review_fingerprint' => hash('sha256', sprintf(
|
|
||||||
'%s:%s:%d',
|
|
||||||
$concernFamily,
|
|
||||||
$manualState,
|
|
||||||
(int) $review->getKey(),
|
|
||||||
)),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
request()->attributes->remove('tenant_resource.triage_review_snapshot');
|
|
||||||
|
|
||||||
return $review->fresh(['reviewer']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@
|
|||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\StoredReport;
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -21,7 +20,6 @@
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
@ -55,17 +53,6 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
OperationRun::factory()->forTenant($tenant)->create();
|
OperationRun::factory()->forTenant($tenant)->create();
|
||||||
}
|
}
|
||||||
|
|
||||||
function evidenceSnapshotHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the evidence list page for an authorized user', function (): void {
|
it('renders the evidence list page for an authorized user', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
@ -138,47 +125,27 @@ function evidenceSnapshotHeaderActions(Testable $component): array
|
|||||||
it('renders the view page for an active snapshot', function (): void {
|
it('renders the view page for an active snapshot', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$run = OperationRun::factory()->forTenant($tenant)->create();
|
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
'operation_run_id' => (int) $run->getKey(),
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||||
'summary' => ['finding_count' => 2],
|
'summary' => ['finding_count' => 2],
|
||||||
'generated_at' => now(),
|
'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(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk();
|
||||||
->assertSee('Related context')
|
|
||||||
->assertSee('Review pack');
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||||
->assertActionVisible('refresh_snapshot')
|
->assertActionVisible('refresh_snapshot')
|
||||||
->assertActionVisible('expire_snapshot');
|
->assertActionVisible('expire_snapshot');
|
||||||
|
|
||||||
expect(collect(evidenceSnapshotHeaderActions($component))
|
|
||||||
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all())
|
|
||||||
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot'])
|
|
||||||
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
|
|
||||||
->toContain('operation_run', 'review_pack');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
||||||
|
|||||||
@ -1,279 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Visible-set baseline')
|
|
||||||
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
|
|
||||||
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
|
|
||||||
->assertDontSee('Passive auto-refresh every 5 seconds')
|
|
||||||
->assertSee('Grouped legend')
|
|
||||||
->assertSee('Apply filters')
|
|
||||||
->assertSee('Compact unlocks at one visible tenant')
|
|
||||||
->assertSee('Dense multi-tenant scan')
|
|
||||||
->assertSee('Open finding')
|
|
||||||
->assertSee('More follow-up')
|
|
||||||
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false)
|
|
||||||
->assertSee('sticky left-0', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$finding = $this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
|
||||||
|
|
||||||
$component = Livewire::withQueryParams([
|
|
||||||
'mode' => 'dense',
|
|
||||||
'policy_type' => ['deviceConfiguration'],
|
|
||||||
'state' => ['differ'],
|
|
||||||
'severity' => ['high'],
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
])
|
|
||||||
->actingAs($fixture['user'])
|
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
|
||||||
->assertSet('requestedMode', 'dense')
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('Focused subject')
|
|
||||||
->assertSee('wifi-corp-profile');
|
|
||||||
|
|
||||||
expect($component->instance()->hasStagedFilterChanges())->toBeFalse();
|
|
||||||
|
|
||||||
$component
|
|
||||||
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
|
|
||||||
->set('draftSelectedStates', ['match'])
|
|
||||||
->set('draftSelectedSeverities', [])
|
|
||||||
->set('draftTenantSort', 'freshness_urgency')
|
|
||||||
->set('draftSubjectSort', 'display_name')
|
|
||||||
->assertSee('Draft filters are staged');
|
|
||||||
|
|
||||||
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
|
|
||||||
|
|
||||||
$component->call('applyFilters')->assertRedirect(
|
|
||||||
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile'
|
|
||||||
);
|
|
||||||
|
|
||||||
$applied = Livewire::withQueryParams([
|
|
||||||
'mode' => 'dense',
|
|
||||||
'policy_type' => ['compliancePolicy'],
|
|
||||||
'state' => ['match'],
|
|
||||||
'tenant_sort' => 'freshness_urgency',
|
|
||||||
'subject_sort' => 'display_name',
|
|
||||||
'subject_key' => 'wifi-corp-profile',
|
|
||||||
])
|
|
||||||
->actingAs($fixture['user'])
|
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]);
|
|
||||||
|
|
||||||
$tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
|
||||||
$findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
|
||||||
|
|
||||||
expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense')
|
|
||||||
->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile')
|
|
||||||
->and(urldecode((string) $findingUrl))->toContain('mode=dense')
|
|
||||||
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
|
||||||
|
|
||||||
$applied->call('resetFilters')->assertRedirect(
|
|
||||||
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', 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' => 'critical'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$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'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Requested: Auto mode. Resolved: Compact mode.')
|
|
||||||
->assertSee('Compact compare results')
|
|
||||||
->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false)
|
|
||||||
->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$fixture['snapshot']->markIncomplete();
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('No usable reference snapshot')
|
|
||||||
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$fixture['profile']->tenantAssignments()->delete();
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('No assigned tenants');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('No visible assigned tenants');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', 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(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Passive auto-refresh every 5 seconds')
|
|
||||||
->assertSee('wire:poll.5s="pollMatrix"', false)
|
|
||||||
->assertSee('Refresh matrix');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenantTwo'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
|
||||||
|
|
||||||
$this->withSession($session)
|
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
|
||||||
->assertSee('No rows match the current filters')
|
|
||||||
->assertSee('Reset filters');
|
|
||||||
});
|
|
||||||
@ -9,26 +9,12 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
function baselineProfileCaptureHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -92,7 +78,7 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
|
|||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
->assertActionVisible('capture')
|
->assertActionVisible('capture')
|
||||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||||
@ -100,16 +86,6 @@ function baselineProfileCaptureHeaderActions(Testable $component): array
|
|||||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
$topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component))
|
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($topLevelActionNames)->toBe(['capture']);
|
|
||||||
|
|
||||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
@ -9,23 +8,9 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function baselineProfileHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||||
@ -147,85 +132,3 @@ function baselineProfileHeaderActions(Testable $component): array
|
|||||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
||||||
|
|
||||||
BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
|
||||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
|
||||||
&& $action->isConfirmationRequired()
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
|
||||||
|
|
||||||
$topLevelActionNames = collect(baselineProfileHeaderActions($component))
|
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
$moreGroup = collect(baselineProfileHeaderActions($component))
|
|
||||||
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible());
|
|
||||||
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($topLevelActionNames)->toBe(['compareNow'])
|
|
||||||
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
|
||||||
->and($moreActionNames)->toEqualCanonicalizing(['compareAssignedTenants', 'edit'])
|
|
||||||
->and(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
|
||||||
->toContain('compare_matrix', 'baseline_snapshot');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
||||||
|
|
||||||
BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
|
||||||
->assertActionDisabled('compareAssignedTenants');
|
|
||||||
|
|
||||||
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
|
||||||
->toContain('compare_matrix');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
|
|
||||||
it('keeps database notifications enabled without background polling on every panel', function (): void {
|
|
||||||
foreach (['admin', 'tenant', 'system'] as $panelId) {
|
|
||||||
$panel = Filament::getPanel($panelId);
|
|
||||||
|
|
||||||
expect($panel->hasDatabaseNotifications())->toBeTrue();
|
|
||||||
expect($panel->getDatabaseNotificationsPollingInterval())->toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the admin notifications modal without a polling attribute', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get('/admin');
|
|
||||||
|
|
||||||
$response->assertSuccessful();
|
|
||||||
|
|
||||||
$html = $response->getContent();
|
|
||||||
|
|
||||||
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
|
|
||||||
|
|
||||||
preg_match('/<[^>]+wire:name="Filament\\\\Livewire\\\\DatabaseNotifications"[^>]*>/', $html, $matches);
|
|
||||||
|
|
||||||
expect($matches)->not->toBeEmpty('Expected the admin page to render the database notifications Livewire root element.');
|
|
||||||
expect($matches[0])->not->toContain('wire:poll');
|
|
||||||
expect($matches[0])->not->toContain('wire:poll.30s');
|
|
||||||
});
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
function editTenantHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTenantHeaderGroupLabels(Testable $component): array
|
|
||||||
{
|
|
||||||
return collect(editTenantHeaderActions($component))
|
|
||||||
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
|
|
||||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTenantHeaderPrimaryNames(Testable $component): array
|
|
||||||
{
|
|
||||||
return collect(editTenantHeaderActions($component))
|
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps related links in contextual placement and reserves the header for lifecycle actions', function (): void {
|
|
||||||
$tenant = Tenant::factory()->onboarding()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
|
||||||
|
|
||||||
createOnboardingDraft([
|
|
||||||
'workspace' => $tenant->workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'started_by' => $user,
|
|
||||||
'updated_by' => $user,
|
|
||||||
'state' => [
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'tenant_name' => (string) $tenant->name,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
|
||||||
->assertSee('Related context')
|
|
||||||
->assertSee('Open tenant detail')
|
|
||||||
->assertSee('Resume onboarding');
|
|
||||||
|
|
||||||
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
|
||||||
->and(editTenantHeaderGroupLabels($component))->toBe([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps tenant lifecycle mutations available under the lifecycle header group with confirmation intact', function (): void {
|
|
||||||
$tenant = Tenant::factory()->active()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
|
||||||
->assertActionVisible('archive')
|
|
||||||
->assertActionEnabled('archive')
|
|
||||||
->assertActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired());
|
|
||||||
|
|
||||||
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
|
||||||
->and(editTenantHeaderGroupLabels($component))->toBe(['Lifecycle']);
|
|
||||||
});
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
function findingExceptionHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findingExceptionHeaderNames(Testable $component): array
|
|
||||||
{
|
|
||||||
return collect(findingExceptionHeaderActions($component))
|
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps finding navigation out of the header while preserving renewal and revocation actions', function (): void {
|
|
||||||
[$requester, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$approver = User::factory()->create();
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'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' => 'Existing compensating controls remain in place.',
|
|
||||||
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
|
||||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$exception = $service->approve($requested, $approver, [
|
|
||||||
'effective_from' => now()->subDay()->toDateTimeString(),
|
|
||||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
|
||||||
'approval_reason' => 'Accepted while remediation is scheduled.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($requester);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$component = Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
|
|
||||||
->assertActionVisible('renew_exception')
|
|
||||||
->assertActionVisible('revoke_exception')
|
|
||||||
->assertActionExists('revoke_exception', fn (Action $action): bool => $action->isConfirmationRequired())
|
|
||||||
->assertSee('Related context')
|
|
||||||
->assertSee('Approval queue')
|
|
||||||
->assertSee('Open finding');
|
|
||||||
|
|
||||||
expect(findingExceptionHeaderNames($component))
|
|
||||||
->toEqualCanonicalizing(['renew_exception', 'revoke_exception'])
|
|
||||||
->not->toContain('open_finding', 'open_approval_queue');
|
|
||||||
});
|
|
||||||
@ -6,20 +6,13 @@
|
|||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||||
|
|
||||||
use function Pest\Laravel\mock;
|
use function Pest\Laravel\mock;
|
||||||
@ -33,18 +26,6 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
|
|||||||
], panel: 'tenant', tenant: $tenant);
|
], panel: 'tenant', tenant: $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Tenant $tenant, array $state): mixed
|
|
||||||
{
|
|
||||||
test()->actingAs($user);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
request()->attributes->remove('portfolio_triage.arrival_context');
|
|
||||||
|
|
||||||
return Livewire::withQueryParams([
|
|
||||||
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
|
|
||||||
])->actingAs($user)->test(TenantTriageArrivalContinuity::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
|
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
|
||||||
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
|
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -183,71 +164,3 @@ function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Tenant
|
|||||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||||
], panel: 'tenant', tenant: $tenant), false);
|
], panel: 'tenant', tenant: $tenant), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows review-state context and requires preview confirmation before marking the current concern reviewed', function (): void {
|
|
||||||
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Review Tenant');
|
|
||||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
|
|
||||||
$component = tenantDashboardArrivalWidget($user, $tenant, [
|
|
||||||
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
||||||
'tenantRouteKey' => (string) $tenant->external_id,
|
|
||||||
'workspaceId' => (int) $tenant->workspace_id,
|
|
||||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
||||||
'returnFilters' => $this->portfolioReturnFilters(
|
|
||||||
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->assertSee('Not reviewed')
|
|
||||||
->assertActionVisible('markReviewed')
|
|
||||||
->assertActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
|
|
||||||
->mountAction('markReviewed');
|
|
||||||
|
|
||||||
expect(TenantTriageReview::query()->count())->toBe(0);
|
|
||||||
|
|
||||||
$component
|
|
||||||
->callMountedAction()
|
|
||||||
->assertSee('Reviewed');
|
|
||||||
|
|
||||||
expect(TenantTriageReview::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
|
||||||
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
|
||||||
->whereNull('resolved_at')
|
|
||||||
->exists())->toBeTrue()
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $tenant->workspace_id)
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders changed-since-review when the current concern fingerprint no longer matches the stored review', function (): void {
|
|
||||||
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Changed Tenant');
|
|
||||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
$this->seedPortfolioTriageReview(
|
|
||||||
$tenant,
|
|
||||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
TenantTriageReview::STATE_REVIEWED,
|
|
||||||
$user,
|
|
||||||
changedFingerprint: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
tenantDashboardArrivalWidget($user, $tenant, [
|
|
||||||
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
||||||
'tenantRouteKey' => (string) $tenant->external_id,
|
|
||||||
'workspaceId' => (int) $tenant->workspace_id,
|
|
||||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
||||||
'returnFilters' => $this->portfolioReturnFilters(
|
|
||||||
[TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->assertSee('Changed since review')
|
|
||||||
->assertSee($user->name);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
|
||||||
|
|
||||||
uses(BuildsPortfolioTriageFixtures::class);
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders review-state badges and all four review-state filters for the current backup slice', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Backup Tenant');
|
|
||||||
|
|
||||||
$reviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Reviewed Backup Tenant');
|
|
||||||
$followUpTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Follow-up Backup Tenant');
|
|
||||||
$changedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Changed Backup Tenant');
|
|
||||||
$notReviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Not Reviewed Backup Tenant');
|
|
||||||
$calmTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Calm Backup Tenant');
|
|
||||||
|
|
||||||
foreach ([$reviewedTenant, $followUpTenant, $changedTenant, $notReviewedTenant] as $tenant) {
|
|
||||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->seedPortfolioBackupConcern($calmTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
|
|
||||||
|
|
||||||
$this->seedPortfolioTriageReview($reviewedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user);
|
|
||||||
$this->seedPortfolioTriageReview($followUpTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $user);
|
|
||||||
$this->seedPortfolioTriageReview($changedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user, changedFingerprint: true);
|
|
||||||
|
|
||||||
$this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
])
|
|
||||||
->assertTableColumnExists('review_state')
|
|
||||||
->assertTableColumnFormattedStateSet('review_state', 'Reviewed', $reviewedTenant)
|
|
||||||
->assertTableColumnFormattedStateSet('review_state', 'Follow-up needed', $followUpTenant)
|
|
||||||
->assertTableColumnFormattedStateSet('review_state', 'Changed since review', $changedTenant)
|
|
||||||
->assertTableColumnFormattedStateSet('review_state', 'Not reviewed', $notReviewedTenant)
|
|
||||||
->assertDontSee('Calm Backup Tenant');
|
|
||||||
|
|
||||||
$reviewedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
])
|
|
||||||
->filterTable('review_state', [TenantTriageReview::STATE_REVIEWED])
|
|
||||||
->instance()
|
|
||||||
->getFilteredTableQuery()
|
|
||||||
?->pluck('tenants.name')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$followUpNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
])
|
|
||||||
->filterTable('review_state', [TenantTriageReview::STATE_FOLLOW_UP_NEEDED])
|
|
||||||
->instance()
|
|
||||||
->getFilteredTableQuery()
|
|
||||||
?->pluck('tenants.name')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$changedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
])
|
|
||||||
->filterTable('review_state', [TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW])
|
|
||||||
->instance()
|
|
||||||
->getFilteredTableQuery()
|
|
||||||
?->pluck('tenants.name')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$notReviewedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
])
|
|
||||||
->filterTable('review_state', [TenantTriageReview::DERIVED_STATE_NOT_REVIEWED])
|
|
||||||
->instance()
|
|
||||||
->getFilteredTableQuery()
|
|
||||||
?->pluck('tenants.name')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($reviewedNames)->toBe(['Reviewed Backup Tenant'])
|
|
||||||
->and($followUpNames)->toBe(['Follow-up Backup Tenant'])
|
|
||||||
->and($changedNames)->toBe(['Changed Backup Tenant'])
|
|
||||||
->and($notReviewedNames)->toBe(['Not Reviewed Backup Tenant']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the highest-priority current concern family when the registry slice is mixed', function (): void {
|
|
||||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Mixed Tenant');
|
|
||||||
$mixedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Mixed Concern Tenant');
|
|
||||||
|
|
||||||
$backupSet = $this->seedPortfolioBackupConcern($mixedTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
$this->seedPortfolioRecoveryConcern($mixedTenant, 'failed', $backupSet);
|
|
||||||
$this->seedPortfolioTriageReview(
|
|
||||||
$mixedTenant,
|
|
||||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
||||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
||||||
$user,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
||||||
])
|
|
||||||
->assertTableColumnFormattedStateSet('review_state', 'Follow-up needed', $mixedTenant)
|
|
||||||
->assertSee('Recovery evidence');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps review-state mutations in overflow with a preview-confirmed write path', function (): void {
|
|
||||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Action Tenant');
|
|
||||||
$actionTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Action Backup Tenant');
|
|
||||||
$this->seedPortfolioBackupConcern($actionTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
|
|
||||||
$component = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
])
|
|
||||||
->assertTableActionVisible('openTenant', $actionTenant)
|
|
||||||
->assertTableActionEnabled('markReviewed', $actionTenant)
|
|
||||||
->assertTableActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'), $actionTenant)
|
|
||||||
->assertTableActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired(), $actionTenant);
|
|
||||||
|
|
||||||
$action = $component->instance()->getAction([
|
|
||||||
[
|
|
||||||
'name' => 'markReviewed',
|
|
||||||
'context' => [
|
|
||||||
'table' => true,
|
|
||||||
'recordKey' => (string) $actionTenant->getKey(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(app(CapabilityResolver::class)->can($user, $actionTenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE))->toBeTrue();
|
|
||||||
|
|
||||||
expect($action)->not->toBeNull()
|
|
||||||
->and($action?->getRecord())->toBeInstanceOf(Tenant::class)
|
|
||||||
->and((int) $action?->getRecord()?->getKey())->toBe((int) $actionTenant->getKey())
|
|
||||||
->and($action?->isDisabled())->toBeFalse()
|
|
||||||
->and($component->instance()->mountedActionShouldOpenModal($action))->toBeTrue();
|
|
||||||
|
|
||||||
$component->mountAction([
|
|
||||||
[
|
|
||||||
'name' => 'markReviewed',
|
|
||||||
'context' => [
|
|
||||||
'table' => true,
|
|
||||||
'recordKey' => (string) $actionTenant->getKey(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mountedAction = $component->instance()->getMountedAction();
|
|
||||||
|
|
||||||
expect($mountedAction)->not->toBeNull()
|
|
||||||
->and($mountedAction?->getRecord())->toBeInstanceOf(Tenant::class)
|
|
||||||
->and((int) $mountedAction?->getRecord()?->getKey())->toBe((int) $actionTenant->getKey());
|
|
||||||
|
|
||||||
$component
|
|
||||||
->callMountedAction();
|
|
||||||
|
|
||||||
expect(TenantTriageReview::query()
|
|
||||||
->where('tenant_id', (int) $actionTenant->getKey())
|
|
||||||
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
|
||||||
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
|
||||||
->whereNull('resolved_at')
|
|
||||||
->exists())->toBeTrue()
|
|
||||||
->and($component->instance())->toBeInstanceOf(ListTenants::class);
|
|
||||||
});
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
function tenantReviewHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tenantReviewHeaderPrimaryNames(Testable $component): array
|
|
||||||
{
|
|
||||||
return collect(tenantReviewHeaderActions($component))
|
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tenantReviewHeaderGroupLabels(Testable $component): array
|
|
||||||
{
|
|
||||||
return collect(tenantReviewHeaderActions($component))
|
|
||||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps ready reviews to one primary action and renders related navigation in the summary context', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user);
|
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
|
||||||
->assertSee('Related context')
|
|
||||||
->assertSee('Evidence snapshot');
|
|
||||||
|
|
||||||
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['publish_review'])
|
|
||||||
->and(tenantReviewHeaderGroupLabels($component))->toBe(['More', 'Danger']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('promotes executive-pack export as the only visible primary action after publication', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user);
|
|
||||||
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
|
||||||
->assertActionVisible('export_executive_pack')
|
|
||||||
->assertActionEnabled('export_executive_pack');
|
|
||||||
|
|
||||||
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['export_executive_pack'])
|
|
||||||
->and(tenantReviewHeaderGroupLabels($component))->toContain('More')
|
|
||||||
->and(tenantReviewHeaderPrimaryNames($component))->not->toContain('refresh_review', 'publish_review');
|
|
||||||
});
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -15,7 +14,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
describe('Tenant View header action UI enforcement', function () {
|
describe('Tenant View header action UI enforcement', function () {
|
||||||
it('keeps archive visible in the workflow header and moves edit/provider navigation into contextual unavailable entries for readonly members', function () {
|
it('shows edit and archive actions as visible but disabled for readonly members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -24,19 +23,19 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('edit')
|
||||||
|
->assertActionDisabled('edit')
|
||||||
|
->assertActionExists('edit', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
})
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionDisabled('archive')
|
->assertActionDisabled('archive')
|
||||||
->assertActionExists('archive', function (Action $action): bool {
|
->assertActionExists('archive', function (Action $action): bool {
|
||||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
});
|
});
|
||||||
|
|
||||||
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
|
|
||||||
|
|
||||||
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('authorization_denied')
|
|
||||||
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps archive enabled for owner members and exposes edit/provider navigation in contextual related content', function () {
|
it('shows edit and archive actions as enabled for owner members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -45,13 +44,10 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('edit')
|
||||||
|
->assertActionEnabled('edit')
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionEnabled('archive');
|
->assertActionEnabled('archive');
|
||||||
|
|
||||||
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
|
|
||||||
|
|
||||||
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('available')
|
|
||||||
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
|
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
|
||||||
@ -89,9 +85,11 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
->assertActionVisible('related_onboarding')
|
||||||
->toBe('Resume onboarding');
|
->assertActionExists('related_onboarding', function (Action $action): bool {
|
||||||
|
return $action->getLabel() === 'Resume onboarding';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
|
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
|
||||||
@ -128,8 +126,10 @@
|
|||||||
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
|
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
|
||||||
->exists())->toBeTrue();
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
->assertActionVisible('related_onboarding')
|
||||||
->toBe('View cancelled onboarding draft');
|
->assertActionExists('related_onboarding', function (Action $action): bool {
|
||||||
|
return $action->getLabel() === 'View cancelled onboarding draft';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,25 +15,6 @@
|
|||||||
expect($js)
|
expect($js)
|
||||||
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
|
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
|
||||||
->toContain("window.addEventListener('unhandledrejection'")
|
->toContain("window.addEventListener('unhandledrejection'")
|
||||||
->toContain('window.fetch = async (...args) =>')
|
|
||||||
->toContain('XMLHttpRequest.prototype.open = function (method, url, ...rest)')
|
|
||||||
->toContain('const transport = resolveTransportMetadata(normalizedReason)')
|
|
||||||
->toContain('requestUrl: transport?.requestUrl ?? null')
|
|
||||||
->toContain('requestMethod: transport?.method ?? null')
|
|
||||||
->toContain('transportType: transport?.transportType ?? null')
|
|
||||||
->toContain('requestUrl: payload.requestUrl')
|
|
||||||
->toContain('isExpectedBackgroundTransportFailure')
|
|
||||||
->toContain("document.visibilityState !== 'visible'")
|
|
||||||
->toContain('document.hasFocus')
|
|
||||||
->toContain('event.preventDefault()')
|
|
||||||
->toContain('status === 419')
|
|
||||||
->toContain('Page Expired')
|
|
||||||
->toContain('status === 404')
|
|
||||||
->toContain('Not Found')
|
|
||||||
->toContain('const dedupeKey = toStableJson({')
|
|
||||||
->toContain('reason: payload.reason')
|
|
||||||
->toContain('TenantPilot unhandled promise rejection')
|
->toContain('TenantPilot unhandled promise rejection')
|
||||||
->toContain('JSON.stringify')
|
->toContain('JSON.stringify');
|
||||||
->not->toContain('recentKeys.has(payloadJson)')
|
|
||||||
->not->toContain('recentKeys.set(payloadJson, nowMs)');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
|
||||||
|
|
||||||
uses(BuildsPortfolioTriageFixtures::class);
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts triage review progress only for the current visible affected set', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Overview Anchor Tenant');
|
|
||||||
$anchorBackup = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
|
|
||||||
workspaceOverviewSeedRestoreHistory($anchorTenant, $anchorBackup, 'completed');
|
|
||||||
|
|
||||||
$reviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Reviewed Tenant');
|
|
||||||
$followUpTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Follow-up Tenant');
|
|
||||||
$changedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Changed Tenant');
|
|
||||||
$calmTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Calm Tenant');
|
|
||||||
|
|
||||||
foreach ([$reviewedTenant, $followUpTenant, $changedTenant] as $tenant) {
|
|
||||||
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
$calmBackup = $this->seedPortfolioBackupConcern($calmTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
|
|
||||||
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
|
|
||||||
|
|
||||||
$this->seedPortfolioTriageReview($reviewedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user);
|
|
||||||
$this->seedPortfolioTriageReview($followUpTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $user);
|
|
||||||
$this->seedPortfolioTriageReview($changedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user, changedFingerprint: true);
|
|
||||||
|
|
||||||
$workspace = $anchorTenant->workspace()->firstOrFail();
|
|
||||||
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
||||||
|
|
||||||
$backupProgress = collect($overview['triage_review_progress'])
|
|
||||||
->firstWhere('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH);
|
|
||||||
|
|
||||||
$backupMetric = collect($overview['summary_metrics'])->firstWhere('key', 'backup_attention_tenants');
|
|
||||||
|
|
||||||
expect($backupProgress['affected_total'])->toBe(3)
|
|
||||||
->and($backupProgress['reviewed_count'])->toBe(1)
|
|
||||||
->and($backupProgress['follow_up_needed_count'])->toBe(1)
|
|
||||||
->and($backupProgress['changed_since_review_count'])->toBe(1)
|
|
||||||
->and($backupProgress['not_reviewed_count'])->toBe(0)
|
|
||||||
->and($backupMetric['description'])->toContain('Reviewed 1/3.')
|
|
||||||
->and($backupProgress['not_reviewed_destination']['url'])->toContain('review_state%5B0%5D=not_reviewed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits triage review progress when the visible workspace slice is calm', function (): void {
|
|
||||||
[$user, $tenant] = $this->makePortfolioTriageActor('Calm Overview Tenant');
|
|
||||||
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
|
|
||||||
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
|
|
||||||
|
|
||||||
$workspace = $tenant->workspace()->firstOrFail();
|
|
||||||
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
|
|
||||||
|
|
||||||
expect($overview['triage_review_progress'])->toBe([]);
|
|
||||||
});
|
|
||||||
@ -78,8 +78,6 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
@ -209,54 +207,6 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('discovers the baseline compare matrix page as an admin-scoped drilldown-only surface', function (): void {
|
|
||||||
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
|
||||||
->keyBy('className');
|
|
||||||
|
|
||||||
$matrixPage = $components->get(\App\Filament\Pages\BaselineCompareMatrix::class);
|
|
||||||
|
|
||||||
expect($matrixPage)->not->toBeNull('BaselineCompareMatrix should be discovered by action surface discovery')
|
|
||||||
->and($matrixPage?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
|
|
||||||
|
|
||||||
$declaration = \App\Filament\Pages\BaselineCompareMatrix::actionSurfaceDeclaration();
|
|
||||||
|
|
||||||
expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? ''))
|
|
||||||
->toContain('compare fan-out')
|
|
||||||
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
|
|
||||||
->toContain('forbids row click');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
|
||||||
|
|
||||||
BaselineTenantAssignment::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(\App\Filament\Pages\BaselineCompareMatrix::class, ['record' => $profile->getKey()])
|
|
||||||
->assertActionVisible('backToBaselineProfile')
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
|
||||||
->assertActionDisabled('compareAssignedTenants')
|
|
||||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to compare the visible assigned set.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -1996,45 +1946,3 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
||||||
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents the spec 192 workflow-heavy exception and reference inventory', function (): void {
|
|
||||||
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
|
|
||||||
$workflowHeavy = collect($inventory)
|
|
||||||
->filter(fn (array $surface): bool => $surface['classification'] === 'workflow_heavy_special_type')
|
|
||||||
->keys()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
$referencePages = collect($inventory)
|
|
||||||
->filter(fn (array $surface): bool => $surface['classification'] === 'compliant_reference')
|
|
||||||
->keys()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($workflowHeavy)->toBe([\App\Filament\Resources\TenantResource\Pages\ViewTenant::class])
|
|
||||||
->and($referencePages)->toEqualCanonicalizing([
|
|
||||||
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
|
|
||||||
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
|
|
||||||
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
|
|
||||||
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
|
|
||||||
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
|
|
||||||
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps spec 192 remediated pages out of the enterprise-detail layout rollout', function (): void {
|
|
||||||
foreach ([
|
|
||||||
\App\Filament\Resources\BaselineProfileResource::class,
|
|
||||||
\App\Filament\Resources\EvidenceSnapshotResource::class,
|
|
||||||
\App\Filament\Resources\FindingExceptionResource::class,
|
|
||||||
\App\Filament\Resources\TenantReviewResource::class,
|
|
||||||
\App\Filament\Resources\TenantResource::class,
|
|
||||||
\App\Filament\Resources\TenantResource\Pages\EditTenant::class,
|
|
||||||
\App\Filament\Resources\TenantResource\Pages\ViewTenant::class,
|
|
||||||
] as $className) {
|
|
||||||
$source = file_get_contents((string) (new ReflectionClass($className))->getFileName()) ?: '';
|
|
||||||
|
|
||||||
expect($source)
|
|
||||||
->not->toContain('EnterpriseDetail')
|
|
||||||
->not->toContain('enterprise-detail/header');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@ -229,11 +229,3 @@ className: $className,
|
|||||||
expect($result->hasIssues())->toBeTrue();
|
expect($result->hasIssues())->toBeTrue();
|
||||||
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts the repository spec 192 inventory even when only inventory validation runs', function (): void {
|
|
||||||
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
|
||||||
|
|
||||||
$result = $validator->validateComponents([]);
|
|
||||||
|
|
||||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
it('does not contain ad-hoc status-like badge semantics', function () {
|
it('does not contain ad-hoc status-like badge semantics', function () {
|
||||||
@ -115,9 +113,3 @@
|
|||||||
|
|
||||||
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
|
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps baseline compare matrix state and freshness surfaces on centralized badge domains', function (): void {
|
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||||
use App\Support\Badges\OperatorStateClassification;
|
use App\Support\Badges\OperatorStateClassification;
|
||||||
|
|
||||||
@ -43,15 +41,3 @@
|
|||||||
|
|
||||||
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
|
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps baseline compare matrix trust aligned with the adopted trust taxonomy and qualified labels', function (): void {
|
|
||||||
$matrixTrust = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, 'diagnostic_only');
|
|
||||||
$operatorTrust = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, 'diagnostic_only');
|
|
||||||
$matrixMissing = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing');
|
|
||||||
$matrixStale = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result');
|
|
||||||
|
|
||||||
expect($matrixTrust->label)->toBe($operatorTrust->label)
|
|
||||||
->and($matrixTrust->color)->toBe($operatorTrust->color)
|
|
||||||
->and($matrixMissing->label)->toBe('Missing from tenant')
|
|
||||||
->and($matrixStale->label)->toBe('Result stale');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
|
||||||
|
|
||||||
function spec192RecordPageSource(string $className): string
|
|
||||||
{
|
|
||||||
$reflection = new ReflectionClass($className);
|
|
||||||
$path = $reflection->getFileName();
|
|
||||||
|
|
||||||
expect($path)->toBeString();
|
|
||||||
|
|
||||||
return file_get_contents((string) $path) ?: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps the spec 192 record-page inventory complete and explicitly classified', function (): void {
|
|
||||||
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
|
|
||||||
|
|
||||||
expect(array_keys($inventory))->toEqualCanonicalizing([
|
|
||||||
\App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile::class,
|
|
||||||
\App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot::class,
|
|
||||||
\App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException::class,
|
|
||||||
\App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview::class,
|
|
||||||
EditTenant::class,
|
|
||||||
ViewTenant::class,
|
|
||||||
\App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection::class,
|
|
||||||
\App\Filament\Resources\FindingResource\Pages\ViewFinding::class,
|
|
||||||
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
|
|
||||||
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
|
|
||||||
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
|
|
||||||
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
|
|
||||||
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
|
|
||||||
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class))
|
|
||||||
->not->toBeNull()
|
|
||||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['classification'] ?? null)->toBe('workflow_heavy_special_type')
|
|
||||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['exceptionReason'] ?? null)->toContain('workflow-heavy hub')
|
|
||||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['classification'] ?? null)->toBe('remediation_required')
|
|
||||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['allowsNoPrimaryAction'] ?? null)->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the spec 192 inventory valid inside the action-surface validator', function (): void {
|
|
||||||
$result = ActionSurfaceValidator::withBaselineExemptions()->validateComponents([]);
|
|
||||||
|
|
||||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps remediated spec 192 pages off the enterprise-detail body-layout builder', function (): void {
|
|
||||||
foreach ([
|
|
||||||
BaselineProfileResource::class,
|
|
||||||
EvidenceSnapshotResource::class,
|
|
||||||
FindingExceptionResource::class,
|
|
||||||
TenantReviewResource::class,
|
|
||||||
TenantResource::class,
|
|
||||||
EditTenant::class,
|
|
||||||
ViewTenant::class,
|
|
||||||
] as $className) {
|
|
||||||
expect(spec192RecordPageSource($className))
|
|
||||||
->not->toContain('EnterpriseDetail')
|
|
||||||
->not->toContain('enterprise-detail/header');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
|
||||||
|
|
||||||
it('returns 404 for non-members on the workspace baseline compare matrix', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$nonMember = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($nonMember)->withSession([
|
|
||||||
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for workspace members missing baseline view capability on the matrix route', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
\App\Models\WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'readonly',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')->andReturnFalse();
|
|
||||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$this->actingAs($viewer)->withSession([
|
|
||||||
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for matrix tenant drilldowns when the actor is not a tenant member', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
$nonMember = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($nonMember)->withSession([
|
|
||||||
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
|
|
||||||
$fixture['profile'],
|
|
||||||
subjectKey: 'wifi-corp-profile',
|
|
||||||
)->toQuery();
|
|
||||||
|
|
||||||
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for matrix tenant drilldowns when tenant view capability is missing', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(CapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')->andReturnFalse();
|
|
||||||
app()->instance(CapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$this->actingAs($fixture['user']);
|
|
||||||
$fixture['visibleTenant']->makeCurrent();
|
|
||||||
|
|
||||||
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
|
|
||||||
$fixture['profile'],
|
|
||||||
subjectKey: 'wifi-corp-profile',
|
|
||||||
tenant: $fixture['visibleTenant'],
|
|
||||||
)->toQuery();
|
|
||||||
|
|
||||||
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for matrix finding drilldowns when findings view capability is missing', function (): void {
|
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$fixture['snapshot'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$finding = $this->makeBaselineCompareMatrixFinding(
|
|
||||||
$fixture['visibleTenant'],
|
|
||||||
$fixture['profile'],
|
|
||||||
$run,
|
|
||||||
'wifi-corp-profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($fixture['user']);
|
|
||||||
$fixture['visibleTenant']->makeCurrent();
|
|
||||||
|
|
||||||
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
|
|
||||||
|
|
||||||
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
|
|
||||||
$fixture['profile'],
|
|
||||||
subjectKey: 'wifi-corp-profile',
|
|
||||||
tenant: $fixture['visibleTenant'],
|
|
||||||
)->toQuery();
|
|
||||||
|
|
||||||
$this->get(FindingResource::getUrl('view', [
|
|
||||||
'record' => $finding,
|
|
||||||
...$query,
|
|
||||||
], tenant: $fixture['visibleTenant']))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
@ -5,22 +5,9 @@
|
|||||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function editTenantUiHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Edit tenant archive action UI enforcement', function () {
|
describe('Edit tenant archive action UI enforcement', function () {
|
||||||
it('shows archive action as visible but disabled for manager members', function () {
|
it('shows archive action as visible but disabled for manager members', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
@ -31,7 +18,7 @@ function editTenantUiHeaderActions(Testable $component): array
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionDisabled('archive')
|
->assertActionDisabled('archive')
|
||||||
->assertActionExists('archive', function (Action $action): bool {
|
->assertActionExists('archive', function (Action $action): bool {
|
||||||
@ -43,12 +30,6 @@ function editTenantUiHeaderActions(Testable $component): array
|
|||||||
->callMountedAction()
|
->callMountedAction()
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
expect(collect(editTenantUiHeaderActions($component))
|
|
||||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
|
||||||
->values()
|
|
||||||
->all())->toBe(['Lifecycle']);
|
|
||||||
|
|
||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
expect($tenant->trashed())->toBeFalse();
|
expect($tenant->trashed())->toBeFalse();
|
||||||
});
|
});
|
||||||
@ -62,7 +43,7 @@ function editTenantUiHeaderActions(Testable $component): array
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionEnabled('archive')
|
->assertActionEnabled('archive')
|
||||||
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
||||||
@ -70,12 +51,6 @@ function editTenantUiHeaderActions(Testable $component): array
|
|||||||
->callMountedAction()
|
->callMountedAction()
|
||||||
->assertHasNoActionErrors();
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
expect(collect(editTenantUiHeaderActions($component))
|
|
||||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
|
||||||
->values()
|
|
||||||
->all())->toBe(['Lifecycle']);
|
|
||||||
|
|
||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
expect($tenant->trashed())->toBeTrue();
|
expect($tenant->trashed())->toBeTrue();
|
||||||
});
|
});
|
||||||
@ -100,16 +75,10 @@ function editTenantUiHeaderActions(Testable $component): array
|
|||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('restore')
|
->assertActionVisible('restore')
|
||||||
->assertActionEnabled('restore')
|
->assertActionEnabled('restore')
|
||||||
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
|
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
|
||||||
->assertActionHidden('archive');
|
->assertActionHidden('archive');
|
||||||
|
|
||||||
expect(collect(editTenantUiHeaderActions($component))
|
|
||||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
|
||||||
->values()
|
|
||||||
->all())->toBe(['Lifecycle']);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,19 +48,15 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->toContain('related_onboarding');
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->toContain('related_onboarding');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
@ -80,18 +76,14 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->not->toContain('related_onboarding');
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->not->toContain('related_onboarding');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
@ -121,19 +113,15 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->toContain('related_onboarding');
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->toContain('related_onboarding');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
|
|||||||
@ -45,12 +45,10 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('related_onboarding')
|
||||||
|
->assertActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
|
||||||
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
|
||||||
->toBe('Resume onboarding');
|
|
||||||
})->with([
|
})->with([
|
||||||
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
@ -93,10 +91,8 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('restore')
|
->assertActionVisible('restore')
|
||||||
->assertActionHidden('archive');
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
|
||||||
->not->toContain('related_onboarding');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows verification only for active tenants on administrative list and detail surfaces', function (
|
it('shows verification only for active tenants on administrative list and detail surfaces', function (
|
||||||
@ -194,10 +190,7 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionVisible('related_onboarding');
|
||||||
|
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($onboardingTenant))->pluck('key')->all())
|
|
||||||
->toContain('related_onboarding');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
||||||
@ -235,10 +228,8 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
|
||||||
->assertActionVisible('restore')
|
->assertActionVisible('restore')
|
||||||
->assertActionHidden('archive');
|
->assertActionHidden('archive')
|
||||||
|
->assertActionHidden('related_onboarding');
|
||||||
expect(collect(TenantResource::tenantViewContextEntries($archivedTenant))->pluck('key')->all())
|
|
||||||
->not->toContain('related_onboarding');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {
|
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
||||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
|
||||||
|
|
||||||
uses(BuildsPortfolioTriageFixtures::class);
|
|
||||||
|
|
||||||
function triageReviewArrivalState(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
|
||||||
'tenantRouteKey' => (string) $tenant->external_id,
|
|
||||||
'workspaceId' => (int) $tenant->workspace_id,
|
|
||||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
||||||
'returnFilters' => [
|
|
||||||
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function triageReviewDashboardWidget(User $user, Tenant $tenant, array $state): mixed
|
|
||||||
{
|
|
||||||
test()->actingAs($user);
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
request()->attributes->remove('portfolio_triage.arrival_context');
|
|
||||||
|
|
||||||
return Livewire::withQueryParams([
|
|
||||||
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
|
|
||||||
])->actingAs($user)->test(TenantTriageArrivalContinuity::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns 404 for non-members on the tenant dashboard triage route', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
|
|
||||||
$foreignTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
$this->seedPortfolioBackupConcern($foreignTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(TenantDashboard::getUrl([
|
|
||||||
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode(triageReviewArrivalState($foreignTenant)),
|
|
||||||
], panel: 'tenant', tenant: $foreignTenant))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows review actions as disabled for readonly members and still rejects a bypassed mutation with 403', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
|
|
||||||
$component = triageReviewDashboardWidget($user, $tenant, triageReviewArrivalState($tenant))
|
|
||||||
->assertActionVisible('markReviewed')
|
|
||||||
->assertActionDisabled('markReviewed');
|
|
||||||
|
|
||||||
$instance = $component->instance();
|
|
||||||
$componentReflection = new ReflectionObject($instance);
|
|
||||||
$cachedActionsProperty = $componentReflection->getProperty('cachedActions');
|
|
||||||
$cachedActionsProperty->setAccessible(true);
|
|
||||||
$cachedActions = $cachedActionsProperty->getValue($instance);
|
|
||||||
$action = $cachedActions['markReviewed'] ?? null;
|
|
||||||
|
|
||||||
expect($action)->not->toBeNull();
|
|
||||||
|
|
||||||
$actionReflection = new ReflectionObject($action);
|
|
||||||
$disabledProperty = $actionReflection->getProperty('isDisabled');
|
|
||||||
$disabledProperty->setAccessible(true);
|
|
||||||
$disabledProperty->setValue($action, false);
|
|
||||||
$cachedActionsProperty->setValue($instance, $cachedActions);
|
|
||||||
|
|
||||||
$instance->mountAction('markReviewed');
|
|
||||||
|
|
||||||
$mountedAction = $instance->getMountedAction();
|
|
||||||
expect($mountedAction)->not->toBeNull();
|
|
||||||
|
|
||||||
$mountedReflection = new ReflectionObject($mountedAction);
|
|
||||||
$mountedDisabledProperty = $mountedReflection->getProperty('isDisabled');
|
|
||||||
$mountedDisabledProperty->setAccessible(true);
|
|
||||||
$mountedDisabledProperty->setValue($mountedAction, false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$instance->callMountedAction();
|
|
||||||
$this->fail('Expected a 403 when bypassing the disabled action.');
|
|
||||||
} catch (HttpException $exception) {
|
|
||||||
expect($exception->getStatusCode())->toBe(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(TenantTriageReview::query()->count())->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes review progress and audit state only after the preview-confirmed action executes', function (): void {
|
|
||||||
[$user, $tenant] = $this->makePortfolioTriageActor('Authorization Success Tenant');
|
|
||||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
||||||
|
|
||||||
$component = triageReviewDashboardWidget($user, $tenant, triageReviewArrivalState($tenant))
|
|
||||||
->assertActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired()
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Target state: Follow-up needed')
|
|
||||||
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
|
|
||||||
->mountAction('markFollowUpNeeded');
|
|
||||||
|
|
||||||
expect(TenantTriageReview::query()->count())->toBe(0);
|
|
||||||
|
|
||||||
$component
|
|
||||||
->callMountedAction();
|
|
||||||
|
|
||||||
expect(TenantTriageReview::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('current_state', TenantTriageReview::STATE_FOLLOW_UP_NEEDED)
|
|
||||||
->whereNull('resolved_at')
|
|
||||||
->exists())->toBeTrue()
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $tenant->workspace_id)
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('action', AuditActionId::TenantTriageReviewMarkedFollowUpNeeded->value)
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
});
|
|
||||||
@ -11,22 +11,8 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function tenantReviewContractHeaderActions(Testable $component): array
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
|
|
||||||
if ($instance->getCachedHeaderActions() === []) {
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $instance->getCachedHeaderActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
|
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
|
||||||
$reflection = new ReflectionClass(TenantReviewResource::class);
|
$reflection = new ReflectionClass(TenantReviewResource::class);
|
||||||
|
|
||||||
@ -117,37 +103,6 @@ function tenantReviewContractHeaderActions(Testable $component): array
|
|||||||
->assertActionMounted('archive_review');
|
->assertActionMounted('archive_review');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$review = composeTenantReviewForTest($tenant, $owner);
|
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
$this->actingAs($owner)
|
|
||||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Related context')
|
|
||||||
->assertSee('Evidence snapshot');
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($owner)
|
|
||||||
->test(ViewTenantReview::class, ['record' => $review->getKey()]);
|
|
||||||
|
|
||||||
$topLevelActionNames = collect(tenantReviewContractHeaderActions($component))
|
|
||||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
$groupLabels = collect(tenantReviewContractHeaderActions($component))
|
|
||||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
|
||||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($topLevelActionNames)->toBe(['publish_review'])
|
|
||||||
->and($groupLabels)->toBe(['More', 'Danger']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -20,8 +20,6 @@
|
|||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Services\Tenants\TenantActionPolicySurface;
|
use App\Services\Tenants\TenantActionPolicySurface;
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
@ -386,9 +384,6 @@ function createUserWithTenant(
|
|||||||
$tenant->getKey() => ['role' => $role],
|
$tenant->getKey() => ['role' => $role],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(CapabilityResolver::class)->clearCache();
|
|
||||||
app(WorkspaceCapabilityResolver::class)->clearCache();
|
|
||||||
|
|
||||||
if ($ensureDefaultMicrosoftProviderConnection) {
|
if ($ensureDefaultMicrosoftProviderConnection) {
|
||||||
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType);
|
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
it('maps baseline compare matrix state badges through centralized semantics', function (): void {
|
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'match')->label)->toBe('Reference aligned')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing')->label)->toBe('Missing from tenant')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'ambiguous')->label)->toBe('Identity ambiguous')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'not_compared')->label)->toBe('Not compared')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps baseline compare matrix freshness badges through centralized semantics', function (): void {
|
|
||||||
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'fresh')->label)->toBe('Current result')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'never_compared')->label)->toBe('Not compared yet')
|
|
||||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'unknown')->label)->toBe('Freshness unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses operator trustworthiness semantics for matrix trust badges', function (): void {
|
|
||||||
foreach (['trustworthy', 'limited_confidence', 'diagnostic_only', 'unusable'] as $state) {
|
|
||||||
$matrixSpec = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, $state);
|
|
||||||
$operatorSpec = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state);
|
|
||||||
|
|
||||||
expect($matrixSpec->label)->toBe($operatorSpec->label)
|
|
||||||
->and($matrixSpec->color)->toBe($operatorSpec->color)
|
|
||||||
->and($matrixSpec->icon)->toBe($operatorSpec->icon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
it('maps triage review states to centralized badge semantics', function (): void {
|
|
||||||
$notReviewed = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
|
|
||||||
$reviewed = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::STATE_REVIEWED);
|
|
||||||
$followUpNeeded = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::STATE_FOLLOW_UP_NEEDED);
|
|
||||||
$changedSinceReview = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW);
|
|
||||||
|
|
||||||
expect($notReviewed->label)->toBe('Not reviewed')
|
|
||||||
->and($notReviewed->color)->toBe('gray')
|
|
||||||
->and($reviewed->label)->toBe('Reviewed')
|
|
||||||
->and($reviewed->color)->toBe('success')
|
|
||||||
->and($followUpNeeded->label)->toBe('Follow-up needed')
|
|
||||||
->and($followUpNeeded->color)->toBe('danger')
|
|
||||||
->and($changedSinceReview->label)->toBe('Changed since review')
|
|
||||||
->and($changedSinceReview->color)->toBe('warning');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the unknown badge semantics for invalid review states', function (): void {
|
|
||||||
$unknown = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, 'invalid_state');
|
|
||||||
|
|
||||||
expect($unknown->label)->toBe('Unknown')
|
|
||||||
->and($unknown->color)->toBe('gray');
|
|
||||||
});
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\BackupHealth\BackupFreshnessEvaluation;
|
|
||||||
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
function triageFingerprintBackupAssessment(
|
|
||||||
int $tenantId,
|
|
||||||
string $posture,
|
|
||||||
?string $reason = null,
|
|
||||||
string $headline = 'Headline',
|
|
||||||
string $supportingMessage = 'Supporting message',
|
|
||||||
): TenantBackupHealthAssessment {
|
|
||||||
return new TenantBackupHealthAssessment(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
posture: $posture,
|
|
||||||
primaryReason: $reason,
|
|
||||||
headline: $headline,
|
|
||||||
supportingMessage: $supportingMessage,
|
|
||||||
latestRelevantBackupSetId: null,
|
|
||||||
latestRelevantCompletedAt: now()->subMinutes(15),
|
|
||||||
qualitySummary: null,
|
|
||||||
freshnessEvaluation: new BackupFreshnessEvaluation(
|
|
||||||
latestCompletedAt: now()->subMinutes(15),
|
|
||||||
cutoffAt: now()->subHour(),
|
|
||||||
isFresh: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
|
|
||||||
),
|
|
||||||
scheduleFollowUp: new BackupScheduleFollowUpEvaluation(
|
|
||||||
hasEnabledSchedules: false,
|
|
||||||
enabledScheduleCount: 0,
|
|
||||||
overdueScheduleCount: 0,
|
|
||||||
failedRecentRunCount: 0,
|
|
||||||
neverSuccessfulCount: 0,
|
|
||||||
needsFollowUp: false,
|
|
||||||
primaryScheduleId: null,
|
|
||||||
summaryMessage: null,
|
|
||||||
),
|
|
||||||
healthyClaimAllowed: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
|
|
||||||
primaryActionTarget: null,
|
|
||||||
positiveClaimBoundary: 'Boundary',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps backup fingerprints deterministic across volatile copy changes', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
$fingerprints = app(TenantTriageReviewFingerprint::class);
|
|
||||||
|
|
||||||
$first = $fingerprints->forBackupHealth(triageFingerprintBackupAssessment(
|
|
||||||
tenantId: 1,
|
|
||||||
posture: TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
||||||
headline: 'Original stale backup',
|
|
||||||
supportingMessage: 'Original wording',
|
|
||||||
));
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 9, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
$second = $fingerprints->forBackupHealth(triageFingerprintBackupAssessment(
|
|
||||||
tenantId: 1,
|
|
||||||
posture: TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
|
||||||
headline: 'Updated stale backup wording',
|
|
||||||
supportingMessage: 'Updated wording',
|
|
||||||
));
|
|
||||||
|
|
||||||
expect($first)->not->toBeNull()
|
|
||||||
->and($second)->not->toBeNull()
|
|
||||||
->and($second['fingerprint'])->toBe($first['fingerprint'])
|
|
||||||
->and($second['snapshot'])->toBe($first['snapshot']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('separates concern families and changes fingerprints when the material concern changes', function (): void {
|
|
||||||
$fingerprints = app(TenantTriageReviewFingerprint::class);
|
|
||||||
|
|
||||||
$backup = $fingerprints->forConcernFamily(
|
|
||||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
triageFingerprintBackupAssessment(
|
|
||||||
tenantId: 1,
|
|
||||||
posture: TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
|
||||||
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$recovery = $fingerprints->forConcernFamily(
|
|
||||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
||||||
null,
|
|
||||||
[
|
|
||||||
'overview_state' => 'weakened',
|
|
||||||
'reason' => 'failed',
|
|
||||||
'latest_relevant_attention_state' => 'failed',
|
|
||||||
'summary' => 'Summary A',
|
|
||||||
'claim_boundary' => 'Boundary A',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$changedRecovery = $fingerprints->forConcernFamily(
|
|
||||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
||||||
null,
|
|
||||||
[
|
|
||||||
'overview_state' => 'weakened',
|
|
||||||
'reason' => 'partial',
|
|
||||||
'latest_relevant_attention_state' => 'partial',
|
|
||||||
'summary' => 'Summary B',
|
|
||||||
'claim_boundary' => 'Boundary B',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($backup)->not->toBeNull()
|
|
||||||
->and($recovery)->not->toBeNull()
|
|
||||||
->and($changedRecovery)->not->toBeNull()
|
|
||||||
->and($backup['concern_family'])->toBe(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
|
||||||
->and($recovery['concern_family'])->toBe(PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE)
|
|
||||||
->and($backup['fingerprint'])->not->toBe($recovery['fingerprint'])
|
|
||||||
->and($recovery['fingerprint'])->not->toBe($changedRecovery['fingerprint']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only emits fingerprints for current affected-set concerns', function (): void {
|
|
||||||
$fingerprints = app(TenantTriageReviewFingerprint::class);
|
|
||||||
|
|
||||||
expect($fingerprints->forBackupHealth(
|
|
||||||
triageFingerprintBackupAssessment(
|
|
||||||
tenantId: 1,
|
|
||||||
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
|
|
||||||
),
|
|
||||||
))->toBeNull()
|
|
||||||
->and($fingerprints->forRecoveryEvidence([
|
|
||||||
'overview_state' => 'no_recent_issues_visible',
|
|
||||||
'reason' => 'no_recent_issues_visible',
|
|
||||||
]))->toBeNull();
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user