Compare commits
5 Commits
188-provid
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f7bbea2623 | |||
| 65e10a2020 | |||
| eca19819d1 | |||
| 2f45ff5a84 | |||
| 1655cc481e |
13
.github/agents/copilot-instructions.md
vendored
13
.github/agents/copilot-instructions.md
vendored
@ -161,6 +161,14 @@ ## Active Technologies
|
||||
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context)
|
||||
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
|
||||
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
|
||||
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
|
||||
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
||||
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -195,8 +203,7 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
|
||||
- 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
|
||||
- 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces
|
||||
- 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
|
||||
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -109,6 +109,15 @@ ### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||
6. Is this current-release truth or future-release preparation?
|
||||
- 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 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.
|
||||
|
||||
|
||||
236
.specify/memory/spec-approval-rubric.md
Normal file
236
.specify/memory/spec-approval-rubric.md
Normal file
@ -0,0 +1,236 @@
|
||||
# TenantPilot Spec Approval Rubric (Anti-Overengineering Guardrails)
|
||||
|
||||
## Leitsatz
|
||||
|
||||
> Kein neuer Layer ohne klaren Operatorgewinn, und kein neuer Spec nur für interne semantische Schönheit.
|
||||
|
||||
Ein neuer Spec ist nur dann stark genug, wenn er **sichtbar mehr Produktwahrheit oder Operator-Wirkung** erzeugt als er dauerhafte Systemkomplexität importiert.
|
||||
|
||||
Jeder Spec muss zwei Dinge gleichzeitig beweisen:
|
||||
|
||||
1. Welches echte Problem wird gelöst?
|
||||
2. Warum ist diese Lösung die kleinste enterprise-taugliche Form?
|
||||
|
||||
Wenn der Spec nur interne Eleganz, feinere Semantik oder mehr Konsistenz bringt, aber keinen klaren Workflow-, Trust- oder Audit-Gewinn, dann ist er **verdächtig**.
|
||||
|
||||
---
|
||||
|
||||
## 5 Pflichtfragen vor jeder Freigabe
|
||||
|
||||
Ein Spec darf nur weiterverfolgt werden, wenn diese 5 Fragen sauber beantwortet sind.
|
||||
|
||||
### A. Welcher konkrete Operator-Workflow wird besser?
|
||||
|
||||
Nicht abstrakt „Konsistenz verbessern", sondern konkret: welcher Nutzer, auf welcher Fläche, in welchem Schritt, mit welchem heutigen Schmerz, und was danach schneller, sicherer oder ehrlicher wird.
|
||||
|
||||
Wenn kein klarer Vorher/Nachher-Workflow benennbar ist → Spec ist zu abstrakt.
|
||||
|
||||
### B. Welche falsche oder gefährliche Produktaussage wird verhindert?
|
||||
|
||||
Legitime Antworten:
|
||||
|
||||
- Falscher „alles okay"-Eindruck
|
||||
- Irreführende Recovery-Claims
|
||||
- Unsaubere Ownership
|
||||
- Fehlende nächste Aktion
|
||||
- Fehlende Audit-Nachvollziehbarkeit
|
||||
- Tenant/Workspace Leakage
|
||||
- RBAC-Missverständnisse
|
||||
|
||||
Wenn ein Spec weder Workflow noch Trust verbessert → kaum zu rechtfertigen.
|
||||
|
||||
### C. Was ist die kleinste brauchbare Version?
|
||||
|
||||
Explizit benennen:
|
||||
|
||||
- Was ist die v1-Minimalversion?
|
||||
- Welche Teile sind bewusst nicht enthalten?
|
||||
- Welche Generalisierung wird absichtlich verschoben?
|
||||
|
||||
Wenn v1 wie ein Framework, eine Plattform oder eine universelle Taxonomie klingt → zu groß.
|
||||
|
||||
### D. Welche dauerhafte Komplexität entsteht?
|
||||
|
||||
Nicht nur Implementierungsaufwand, sondern Dauerfolgen:
|
||||
|
||||
- Neue Models / Tables?
|
||||
- Neue Enums / Statusachsen?
|
||||
- Neue UI-Semantik?
|
||||
- Neue cross-surface Contracts?
|
||||
- Neue Tests, die dauerhaft gepflegt werden müssen?
|
||||
- Neue Begriffe, die jeder verstehen muss?
|
||||
|
||||
Wenn die Liste lang ist → Produktgewinn muss entsprechend hoch sein.
|
||||
|
||||
### E. Warum jetzt?
|
||||
|
||||
Legitime Gründe:
|
||||
|
||||
- Blockiert Kernworkflow
|
||||
- Verhindert gefährliche Fehlinterpretation
|
||||
- Ist Voraussetzung für unmittelbar folgende Hauptdomäne
|
||||
- Beseitigt echten systemischen Widerspruch
|
||||
- Wird bereits von mehreren Flächen schmerzhaft benötigt
|
||||
|
||||
Schwache Gründe:
|
||||
|
||||
- „wäre sauberer"
|
||||
- „brauchen wir später bestimmt"
|
||||
- „passt gut zur Architektur"
|
||||
- „macht das Modell vollständiger"
|
||||
|
||||
---
|
||||
|
||||
## 4 Spec-Klassen
|
||||
|
||||
Jeden Kandidaten zwingend in genau eine Klasse einordnen.
|
||||
|
||||
### Klasse 1 — Core Enterprise Spec
|
||||
|
||||
Mindestens eins muss stimmen:
|
||||
|
||||
- Schützt echte System-/Tenant-/RBAC-Korrektheit
|
||||
- Verhindert falsche Governance-/Recovery-/Audit-Aussagen
|
||||
- Schließt klaren Workflow-Gap
|
||||
- Beseitigt cross-surface Widerspruch mit realem Operator-Schaden
|
||||
- Ist echte Voraussetzung für eine wichtige Produktfunktion
|
||||
|
||||
Dürfen Komplexität einführen, aber nur gezielt.
|
||||
|
||||
### Klasse 2 — Workflow Compression Spec
|
||||
|
||||
Gut, wenn sie:
|
||||
|
||||
- Klickpfade verkürzen
|
||||
- Kontextverlust senken
|
||||
- Return-/Drilldown-Kontinuität verbessern
|
||||
- Triage-/Review-/Run-Bearbeitung beschleunigen
|
||||
|
||||
Nützlich, aber klein halten.
|
||||
|
||||
### Klasse 3 — Cleanup / Consolidation
|
||||
|
||||
- Vereinfachung, Zusammenführung, Entkopplung
|
||||
- Entfernen von Legacy / Duplikaten
|
||||
- Reduktion unnötiger Schichten
|
||||
|
||||
Explizit erwünscht als Gegengewicht zu Wachstum.
|
||||
|
||||
### Klasse 4 — Premature / Defer
|
||||
|
||||
Wenn der Kandidat hauptsächlich bringt:
|
||||
|
||||
- Neue Semantik, Frameworks, Taxonomien
|
||||
- Generalisierung für künftige Fälle
|
||||
- Infrastruktur ohne breite aktuelle Nutzung
|
||||
|
||||
→ Nicht freigeben. Verschieben oder brutal einkürzen.
|
||||
|
||||
---
|
||||
|
||||
## Rote Flaggen
|
||||
|
||||
Wenn **zwei oder mehr** zutreffen → Spec muss aktiv verteidigt werden.
|
||||
|
||||
| # | Rote Flagge | Prüffrage |
|
||||
|---|---|---|
|
||||
| 1 | **Neue Achsen** — neues Truth-Modell, Statusdimension, Taxonomie, Bewertungsachse | Braucht der Operator das wirklich, oder nur das Modell? |
|
||||
| 2 | **Neue Meta-Infrastruktur** — Presenter, Resolver, Catalog, Matrix, Registry, Builder, Policy-Layer | Sehr hoher Beweiswert nötig. |
|
||||
| 3 | **Viele Flächen, wenig Nutzerwert** — 6 Flächen „harmonisiert", kein klarer Nutzerflow besser | Architektur um ihrer selbst willen? |
|
||||
| 4 | **Klingt nach Foundation** — foundation, framework, generalized, reusable, future-proof, canonical semantics | Fast immer erklärungsbedürftig. |
|
||||
| 5 | **Mehr Begriffe als Outcomes** — lange semantische Erklärung, Nutzerverbesserung kaum in einem Satz | Verdächtig. |
|
||||
| 6 | **Mehrere Mikrospecs für eine Domäne** — foundation + semantics + presentation + hardening + integration | Zu fein zerlegt. |
|
||||
|
||||
---
|
||||
|
||||
## Grüne Flaggen
|
||||
|
||||
- Löst klar beobachtbaren Operator-Schmerz
|
||||
- Verbessert echte Entscheidungssituation
|
||||
- Verhindert konkrete Fehlinterpretation
|
||||
- Reduziert Navigation oder Denkaufwand
|
||||
- Vereinfacht bereits existierende Komplexität
|
||||
- Führt wenig neue Begriffe ein
|
||||
- Hat klare Nicht-Ziele
|
||||
- Ist in einer Sitzung gut erklärbar
|
||||
- Braucht keine neue Meta-Schicht
|
||||
- Macht mehrere Flächen einfacher statt abstrakter
|
||||
|
||||
---
|
||||
|
||||
## Bewertungsraster (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,6 +5,24 @@ # Feature Specification: [FEATURE NAME]
|
||||
**Status**: Draft
|
||||
**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)*
|
||||
|
||||
- **Scope**: [workspace | tenant | canonical-view]
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionClassificationResult;
|
||||
use App\Services\Providers\ProviderConnectionClassifier;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
@ -29,10 +28,8 @@ class ClassifyProviderConnections extends Command
|
||||
|
||||
protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
|
||||
|
||||
public function handle(
|
||||
ProviderConnectionClassifier $classifier,
|
||||
ProviderConnectionStateProjector $stateProjector,
|
||||
): int {
|
||||
public function handle(ProviderConnectionClassifier $classifier): int
|
||||
{
|
||||
$query = $this->query();
|
||||
$write = (bool) $this->option('write');
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
@ -62,7 +59,6 @@ public function handle(
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($connections) use (
|
||||
$classifier,
|
||||
$stateProjector,
|
||||
$write,
|
||||
$tenantCounts,
|
||||
&$startedTenants,
|
||||
@ -101,7 +97,7 @@ public function handle(
|
||||
$startedTenants[$tenantKey] = true;
|
||||
}
|
||||
|
||||
$connection = $this->applyClassification($connection, $result, $stateProjector);
|
||||
$connection = $this->applyClassification($connection, $result);
|
||||
$this->auditApplied($tenant, $connection, $result);
|
||||
$appliedCount++;
|
||||
}
|
||||
@ -146,11 +142,10 @@ private function query(): Builder
|
||||
private function applyClassification(
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionClassificationResult $result,
|
||||
ProviderConnectionStateProjector $stateProjector,
|
||||
): ProviderConnection {
|
||||
DB::transaction(function () use ($connection, $result, $stateProjector): void {
|
||||
DB::transaction(function () use ($connection, $result): void {
|
||||
$connection->forceFill(
|
||||
$connection->classificationProjection($result, $stateProjector)
|
||||
$connection->classificationProjection($result)
|
||||
)->save();
|
||||
|
||||
$credential = $connection->credential;
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -109,6 +110,13 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, mixed>|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
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -130,6 +138,12 @@ public static function canAccess(): bool
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@ -244,6 +258,9 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
|
||||
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
|
||||
'matrixSubjectKey' => $this->matrixSubjectKey,
|
||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->compareNowAction(),
|
||||
];
|
||||
$actions = [];
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('backToOrigin')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
}
|
||||
|
||||
$actions[] = $this->compareNowAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function compareNowAction(): Action
|
||||
@ -389,7 +416,7 @@ private function compareNowAction(): Action
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url(OperationRunLinks::view($run, $tenant)),
|
||||
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
|
||||
] : [])
|
||||
->send();
|
||||
});
|
||||
@ -436,4 +463,15 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
560
apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
Normal file
560
apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
Normal file
@ -0,0 +1,560 @@
|
||||
<?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\CheckboxList;
|
||||
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';
|
||||
|
||||
/**
|
||||
* @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 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([
|
||||
CheckboxList::make('selectedPolicyTypes')
|
||||
->label('Policy types')
|
||||
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
|
||||
->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',
|
||||
])
|
||||
->columns(1)
|
||||
->columnSpan([
|
||||
'lg' => 2,
|
||||
])
|
||||
->live(),
|
||||
CheckboxList::make('selectedStates')
|
||||
->label('Technical states')
|
||||
->options(fn (): array => $this->matrixOptions('stateOptions'))
|
||||
->columnSpan([
|
||||
'lg' => 2,
|
||||
])
|
||||
->columns(1)
|
||||
->live(),
|
||||
CheckboxList::make('selectedSeverities')
|
||||
->label('Severity')
|
||||
->options(fn (): array => $this->matrixOptions('severityOptions'))
|
||||
->columns(1)
|
||||
->live(),
|
||||
])
|
||||
->columnSpan([
|
||||
'xl' => 1,
|
||||
]),
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
'md' => 2,
|
||||
'xl' => 1,
|
||||
])
|
||||
->schema([
|
||||
Select::make('tenantSort')
|
||||
->label('Tenant sort')
|
||||
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
||||
->default('tenant_name')
|
||||
->native(false)
|
||||
->live()
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
|
||||
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
|
||||
Select::make('subjectSort')
|
||||
->label('Subject sort')
|
||||
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
|
||||
->default('deviation_breadth')
|
||||
->native(false)
|
||||
->live()
|
||||
->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 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 filterUrl(array $overrides = []): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
|
||||
}
|
||||
|
||||
public function updatedSelectedPolicyTypes(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function updatedSelectedStates(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function updatedSelectedSeverities(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function updatedTenantSort(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function updatedSubjectSort(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function updatedFocusedSubjectKey(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function activeFilterCount(): int
|
||||
{
|
||||
return count($this->selectedPolicyTypes)
|
||||
+ count($this->selectedStates)
|
||||
+ count($this->selectedSeverities)
|
||||
+ ($this->focusedSubjectKey !== null ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return array_merge($this->matrix, [
|
||||
'profile' => $this->getRecord(),
|
||||
'currentFilters' => [
|
||||
'policy_type' => $this->selectedPolicyTypes,
|
||||
'state' => $this->selectedStates,
|
||||
'severity' => $this->selectedSeverities,
|
||||
'tenant_sort' => $this->tenantSort,
|
||||
'subject_sort' => $this->subjectSort,
|
||||
'subject_key' => $this->focusedSubjectKey,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function hydrateFiltersFromRequest(): void
|
||||
{
|
||||
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
|
||||
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
|
||||
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
|
||||
$this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name';
|
||||
$this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth';
|
||||
$subjectKey = request()->query('subject_key');
|
||||
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function filterFormState(): array
|
||||
{
|
||||
return [
|
||||
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||
'selectedStates' => $this->selectedStates,
|
||||
'selectedSeverities' => $this->selectedSeverities,
|
||||
'tenantSort' => $this->tenantSort,
|
||||
'subjectSort' => $this->subjectSort,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function matrixOptions(string $key): array
|
||||
{
|
||||
$options = $this->matrix[$key] ?? null;
|
||||
|
||||
return is_array($options) ? $options : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 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(),
|
||||
'policy_type' => $this->selectedPolicyTypes,
|
||||
'state' => $this->selectedStates,
|
||||
'severity' => $this->selectedSeverities,
|
||||
'tenant_sort' => $this->tenantSort,
|
||||
'subject_sort' => $this->subjectSort,
|
||||
'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();
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,6 @@
|
||||
use App\Services\Onboarding\OnboardingLifecycleService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Services\Providers\ProviderOperationRegistry;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
@ -2535,12 +2534,6 @@ public function createProviderConnection(array $data): void
|
||||
|
||||
/** @var ProviderConnection $connection */
|
||||
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection {
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
@ -2554,15 +2547,14 @@ public function createProviderConnection(array $data): void
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => $displayName,
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -135,7 +136,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->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.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
@ -447,10 +448,16 @@ public static function getPages(): array
|
||||
'index' => Pages\ListBaselineProfiles::route('/'),
|
||||
'create' => Pages\CreateBaselineProfile::route('/create'),
|
||||
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
||||
'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'),
|
||||
'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>
|
||||
*/
|
||||
|
||||
@ -44,6 +44,8 @@ protected function getHeaderActions(): array
|
||||
->color('gray'),
|
||||
$this->captureAction(),
|
||||
$this->compareNowAction(),
|
||||
$this->openCompareMatrixAction(),
|
||||
$this->compareAssignedTenantsAction(),
|
||||
EditAction::make()
|
||||
->visible(fn (): bool => $this->hasManageCapability()),
|
||||
];
|
||||
@ -307,6 +309,80 @@ private function compareNowAction(): Action
|
||||
});
|
||||
}
|
||||
|
||||
private function openCompareMatrixAction(): Action
|
||||
{
|
||||
return Action::make('openCompareMatrix')
|
||||
->label('Open compare matrix')
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->color('gray')
|
||||
->url(fn (): string => BaselineProfileResource::compareMatrixUrl($this->getRecord()));
|
||||
}
|
||||
|
||||
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>
|
||||
*/
|
||||
@ -407,4 +483,48 @@ private function profileHasConsumableSnapshot(): bool
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1257,6 +1257,16 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false
|
||||
|
||||
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;
|
||||
|
||||
return new CanonicalNavigationContext(
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions;
|
||||
@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$actions = [];
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Actions\Action::make('back_to_origin')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
}
|
||||
|
||||
return array_merge($actions, [
|
||||
Actions\Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
@ -53,11 +64,16 @@ protected function getHeaderActions(): array
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
public function getSubheading(): string|Htmlable|null
|
||||
{
|
||||
return FindingResource::findingSubheading($this->getRecord());
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
}
|
||||
|
||||
@ -472,6 +472,11 @@ private static function consentStatusLabelFromState(mixed $state): string
|
||||
return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label;
|
||||
}
|
||||
|
||||
private static function lifecycleLabelFromState(mixed $state): string
|
||||
{
|
||||
return BadgeRenderer::spec(BadgeDomain::BooleanEnabled, $state)->label;
|
||||
}
|
||||
|
||||
private static function verificationStatusLabelFromState(mixed $state): string
|
||||
{
|
||||
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
||||
@ -512,6 +517,9 @@ public static function form(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
Section::make('Current state')
|
||||
->schema([
|
||||
Placeholder::make('is_enabled_display')
|
||||
->label('Lifecycle')
|
||||
->content(fn (?ProviderConnection $record): string => static::lifecycleLabelFromState($record?->is_enabled)),
|
||||
Placeholder::make('consent_status_display')
|
||||
->label('Consent')
|
||||
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
|
||||
@ -526,12 +534,6 @@ public static function form(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
Section::make('Diagnostics')
|
||||
->schema([
|
||||
Placeholder::make('status_display')
|
||||
->label('Legacy status')
|
||||
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label),
|
||||
Placeholder::make('health_status_display')
|
||||
->label('Legacy health')
|
||||
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label),
|
||||
Placeholder::make('migration_review_status_display')
|
||||
->label('Migration review')
|
||||
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
@ -578,6 +580,13 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2),
|
||||
Section::make('Current state')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('is_enabled')
|
||||
->label('Lifecycle')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state): string => static::lifecycleLabelFromState($state))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
Infolists\Components\TextEntry::make('consent_status')
|
||||
->label('Consent')
|
||||
->badge()
|
||||
@ -599,20 +608,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2),
|
||||
Section::make('Diagnostics')
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->label('Legacy status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
||||
Infolists\Components\TextEntry::make('health_status')
|
||||
->label('Legacy health')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||
Infolists\Components\TextEntry::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||
@ -684,6 +679,13 @@ public static function table(Table $table): Table
|
||||
? 'Dedicated'
|
||||
: 'Platform')
|
||||
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
||||
Tables\Columns\TextColumn::make('is_enabled')
|
||||
->label('Lifecycle')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
Tables\Columns\TextColumn::make('consent_status')
|
||||
->label('Consent')
|
||||
->badge()
|
||||
@ -698,22 +700,6 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->label('Legacy status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('health_status')
|
||||
->label('Legacy health')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('migration_review_required')
|
||||
->label('Migration review')
|
||||
->badge()
|
||||
@ -796,12 +782,10 @@ public static function table(Table $table): Table
|
||||
|
||||
return $query->where('provider_connections.verification_status', $value);
|
||||
}),
|
||||
SelectFilter::make('status')
|
||||
->label('Diagnostic status')
|
||||
SelectFilter::make('is_enabled')
|
||||
->label('Lifecycle')
|
||||
->options([
|
||||
'connected' => 'Connected',
|
||||
'needs_consent' => 'Needs consent',
|
||||
'error' => 'Error',
|
||||
'enabled' => 'Enabled',
|
||||
'disabled' => 'Disabled',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
@ -811,24 +795,7 @@ public static function table(Table $table): Table
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('provider_connections.status', $value);
|
||||
}),
|
||||
SelectFilter::make('health_status')
|
||||
->label('Diagnostic health')
|
||||
->options([
|
||||
'ok' => 'OK',
|
||||
'degraded' => 'Degraded',
|
||||
'down' => 'Down',
|
||||
'unknown' => 'Unknown',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('provider_connections.health_status', $value);
|
||||
return $query->where('provider_connections.is_enabled', $value === 'enabled');
|
||||
}),
|
||||
Filter::make('default_only')
|
||||
->label('Default only')
|
||||
@ -847,7 +814,7 @@ public static function table(Table $table): Table
|
||||
->label('Check connection')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
@ -946,7 +913,7 @@ public static function table(Table $table): Table
|
||||
->label('Inventory sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
@ -1043,7 +1010,7 @@ public static function table(Table $table): Table
|
||||
->label('Compliance snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
@ -1141,7 +1108,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-star')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled && ! $record->is_default)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
@ -1383,7 +1350,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
@ -1392,15 +1359,14 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$previousStatus = (string) $record->status;
|
||||
|
||||
$status = $hadCredentials ? 'connected' : 'error';
|
||||
$previousLifecycle = (bool) $record->is_enabled;
|
||||
$verificationStatus = $hadCredentials ? ProviderVerificationStatus::Unknown : ProviderVerificationStatus::Blocked;
|
||||
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
|
||||
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
|
||||
|
||||
$record->update([
|
||||
'status' => $status,
|
||||
'health_status' => 'unknown',
|
||||
'is_enabled' => true,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => $errorReasonCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
@ -1418,8 +1384,9 @@ public static function table(Table $table): Table
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => $status,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'enabled',
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'credentials_present' => $hadCredentials,
|
||||
],
|
||||
],
|
||||
@ -1457,7 +1424,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
|
||||
@ -1465,10 +1432,10 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$previousStatus = (string) $record->status;
|
||||
$previousLifecycle = (bool) $record->is_enabled;
|
||||
|
||||
$record->update([
|
||||
'status' => 'disabled',
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
@ -1483,7 +1450,8 @@ public static function table(Table $table): Table
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_status' => $previousStatus,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'disabled',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
@ -30,27 +29,20 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
return [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $data['entra_tenant_id'],
|
||||
'display_name' => $data['display_name'],
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
|
||||
@ -212,7 +212,7 @@ protected function getHeaderActions(): array
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
&& (bool) $record->is_enabled;
|
||||
})
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
$tenant = $this->currentTenant();
|
||||
@ -521,7 +521,7 @@ protected function getHeaderActions(): array
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||
&& $record->status !== 'disabled'
|
||||
&& (bool) $record->is_enabled
|
||||
&& ! $record->is_default
|
||||
&& ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
@ -581,7 +581,7 @@ protected function getHeaderActions(): array
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
&& (bool) $record->is_enabled;
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = $this->currentTenant();
|
||||
@ -695,7 +695,7 @@ protected function getHeaderActions(): array
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
&& (bool) $record->is_enabled;
|
||||
})
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
$tenant = $this->currentTenant();
|
||||
@ -803,7 +803,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
@ -812,15 +812,19 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$previousStatus = (string) $record->status;
|
||||
|
||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||
$previousLifecycle = (bool) $record->is_enabled;
|
||||
$verificationStatus = $hadCredentials ? \App\Support\Providers\ProviderVerificationStatus::Unknown : \App\Support\Providers\ProviderVerificationStatus::Blocked;
|
||||
$errorReasonCode = null;
|
||||
$errorMessage = null;
|
||||
|
||||
if (! $hadCredentials) {
|
||||
$errorReasonCode = \App\Support\Providers\ProviderReasonCodes::ProviderCredentialMissing;
|
||||
$errorMessage = 'Provider connection credentials are missing.';
|
||||
}
|
||||
|
||||
$record->update([
|
||||
'status' => $status,
|
||||
'health_status' => 'unknown',
|
||||
'is_enabled' => true,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => $errorReasonCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
@ -838,8 +842,9 @@ protected function getHeaderActions(): array
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => $status,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'enabled',
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'credentials_present' => $hadCredentials,
|
||||
],
|
||||
],
|
||||
@ -853,8 +858,8 @@ protected function getHeaderActions(): array
|
||||
|
||||
if (! $hadCredentials) {
|
||||
Notification::make()
|
||||
->title('Connection enabled (needs consent)')
|
||||
->body('Grant admin consent before running checks or operations.')
|
||||
->title('Connection enabled (credentials missing)')
|
||||
->body('Add credentials before running checks or operations.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -878,7 +883,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled)
|
||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
@ -886,10 +891,10 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
$previousStatus = (string) $record->status;
|
||||
$previousLifecycle = (bool) $record->is_enabled;
|
||||
|
||||
$record->update([
|
||||
'status' => 'disabled',
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
@ -904,7 +909,8 @@ protected function getHeaderActions(): array
|
||||
'metadata' => [
|
||||
'provider' => $record->provider,
|
||||
'entra_tenant_id' => $record->entra_tenant_id,
|
||||
'from_status' => $previousStatus,
|
||||
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
|
||||
'to_lifecycle' => 'disabled',
|
||||
],
|
||||
],
|
||||
actorId: $actorId,
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -25,6 +26,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\PortfolioTriage\TenantTriageReviewService;
|
||||
use App\Services\Tenants\TenantActionPolicySurface;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Services\Verification\StartVerification;
|
||||
@ -42,6 +44,7 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\TenantActionDescriptor;
|
||||
@ -103,6 +106,8 @@ class TenantResource extends Resource
|
||||
|
||||
private const string POSTURE_SNAPSHOT_REQUEST_KEY = 'tenant_resource.posture_snapshot';
|
||||
|
||||
private const string TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY = 'tenant_resource.triage_review_snapshot';
|
||||
|
||||
/**
|
||||
* @var array<string, true>
|
||||
*/
|
||||
@ -302,6 +307,21 @@ public static function table(Table $table): Table
|
||||
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceTone(
|
||||
static::recoveryEvidenceForTenant($record),
|
||||
)),
|
||||
Tables\Columns\TextColumn::make('review_state')
|
||||
->label('Review state')
|
||||
->badge()
|
||||
->state(fn (Tenant $record, mixed $livewire): ?string => static::selectedTriageReviewRowForTenant(
|
||||
$record,
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
)['derived_state'] ?? null)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantTriageReviewState))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantTriageReviewState))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantTriageReviewState))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantTriageReviewState))
|
||||
->description(fn (Tenant $record, mixed $livewire): ?string => static::triageReviewDescriptionForTenant(
|
||||
$record,
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
)),
|
||||
Tables\Columns\TextColumn::make('tenant_id')
|
||||
->label('Tenant ID')
|
||||
->copyable()
|
||||
@ -375,6 +395,17 @@ public static function table(Table $table): Table
|
||||
static::sanitizeRecoveryEvidenceStates($data['values'] ?? []),
|
||||
);
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('review_state')
|
||||
->label('Review state')
|
||||
->multiple()
|
||||
->options(static::reviewStateOptions())
|
||||
->query(function (Builder $query, array $data, mixed $livewire): Builder {
|
||||
return static::applyReviewStateFilter(
|
||||
$query,
|
||||
static::sanitizeReviewStates($data['values'] ?? []),
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
);
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('triage_sort')
|
||||
->label('Sort order')
|
||||
->options(TenantRecoveryTriagePresentation::triageSortOptions())
|
||||
@ -387,26 +418,23 @@ public static function table(Table $table): Table
|
||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(function (?Tenant $record = null, mixed $livewire = null): string {
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(function (?Tenant $record = null, mixed $livewire = null): string {
|
||||
if (! $record instanceof Tenant) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
$triageState = $livewire instanceof Pages\ListTenants
|
||||
? static::portfolioReturnFilters(
|
||||
static::backupPostureState($livewire),
|
||||
static::recoveryEvidenceState($livewire),
|
||||
static::triageSortState($livewire),
|
||||
)
|
||||
? static::currentPortfolioTriageState($livewire)
|
||||
: [];
|
||||
|
||||
if (! static::hasActivePortfolioTriageState(
|
||||
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||
)) {
|
||||
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||
@ -684,6 +712,70 @@ public static function table(Table $table): Table
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
Actions\Action::make('markReviewed')
|
||||
->label('Mark reviewed')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Mark reviewed')
|
||||
->modalDescription(fn (Tenant $record, mixed $livewire): string => static::triageReviewActionModalDescription(
|
||||
$record,
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
TenantTriageReview::STATE_REVIEWED,
|
||||
))
|
||||
->visible(fn (Tenant $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
|
||||
$record,
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
) !== null && static::userCanSeeTriageReviewAction($record))
|
||||
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
|
||||
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
|
||||
->before(function (Tenant $record): void {
|
||||
static::authorizeTriageReviewAction($record);
|
||||
})
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
mixed $livewire,
|
||||
TenantTriageReviewService $service,
|
||||
): void {
|
||||
static::handleTriageReviewMutation(
|
||||
tenant: $record,
|
||||
triageState: static::currentPortfolioTriageState($livewire),
|
||||
targetManualState: TenantTriageReview::STATE_REVIEWED,
|
||||
service: $service,
|
||||
);
|
||||
}),
|
||||
Actions\Action::make('markFollowUpNeeded')
|
||||
->label('Mark follow-up needed')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Mark follow-up needed')
|
||||
->modalDescription(fn (Tenant $record, mixed $livewire): string => static::triageReviewActionModalDescription(
|
||||
$record,
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
))
|
||||
->visible(fn (Tenant $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
|
||||
$record,
|
||||
static::currentPortfolioTriageState($livewire),
|
||||
) !== null && static::userCanSeeTriageReviewAction($record))
|
||||
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
|
||||
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
|
||||
->before(function (Tenant $record): void {
|
||||
static::authorizeTriageReviewAction($record);
|
||||
})
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
mixed $livewire,
|
||||
TenantTriageReviewService $service,
|
||||
): void {
|
||||
static::handleTriageReviewMutation(
|
||||
tenant: $record,
|
||||
triageState: static::currentPortfolioTriageState($livewire),
|
||||
targetManualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
service: $service,
|
||||
);
|
||||
}),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||
@ -929,6 +1021,17 @@ public static function sanitizeRecoveryEvidenceStates(mixed $value): array
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function sanitizeReviewStates(mixed $value): array
|
||||
{
|
||||
return static::sanitizeRequestedValues(
|
||||
$value,
|
||||
TenantTriageReview::DERIVED_STATES,
|
||||
);
|
||||
}
|
||||
|
||||
public static function sanitizeTriageSort(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || ! isset(self::TRIAGE_SORT_VALUES[$value])) {
|
||||
@ -942,6 +1045,7 @@ public static function sanitizeTriageSort(mixed $value): ?string
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
@ -966,6 +1070,7 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
* @return array<string, mixed>|null
|
||||
@ -974,97 +1079,36 @@ private static function portfolioArrivalStateForTenant(Tenant $record, array $tr
|
||||
{
|
||||
$backupPostures = static::sanitizeBackupPostures($triageState['backup_posture'] ?? []);
|
||||
$recoveryEvidenceFilters = static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []);
|
||||
$reviewStates = static::sanitizeReviewStates($triageState['review_state'] ?? []);
|
||||
$triageSort = static::sanitizeTriageSort($triageState['triage_sort'] ?? null);
|
||||
|
||||
if (! static::hasActivePortfolioTriageState($backupPostures, $recoveryEvidenceFilters, $triageSort)) {
|
||||
if (! static::hasActivePortfolioTriageState($backupPostures, $recoveryEvidenceFilters, $reviewStates, $triageSort)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupAssessment = static::backupHealthAssessmentForTenant($record);
|
||||
$backupPosture = $backupAssessment?->posture;
|
||||
$recoveryEvidence = static::recoveryEvidenceForTenant($record);
|
||||
$recoveryState = is_array($recoveryEvidence)
|
||||
? TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence)
|
||||
: null;
|
||||
$matchedConcerns = [];
|
||||
$selectedReviewRow = static::selectedTriageReviewRowForTenant(
|
||||
$record,
|
||||
static::portfolioReturnFilters(
|
||||
$backupPostures,
|
||||
$recoveryEvidenceFilters,
|
||||
$reviewStates,
|
||||
$triageSort,
|
||||
),
|
||||
);
|
||||
|
||||
if ($backupAssessment instanceof TenantBackupHealthAssessment
|
||||
&& $backupPostures !== []
|
||||
&& in_array($backupPosture, $backupPostures, true)
|
||||
&& in_array($backupPosture, [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
], true)) {
|
||||
$matchedConcerns[] = [
|
||||
'priority' => static::portfolioConcernPriority('backup_health', $backupPosture),
|
||||
'family' => 'backup_health',
|
||||
'state' => $backupPosture,
|
||||
'reason' => $backupAssessment->primaryReason,
|
||||
];
|
||||
}
|
||||
|
||||
if (is_string($recoveryState)
|
||||
&& $recoveryEvidenceFilters !== []
|
||||
&& in_array($recoveryState, $recoveryEvidenceFilters, true)
|
||||
&& in_array($recoveryState, [
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||
], true)) {
|
||||
$matchedConcerns[] = [
|
||||
'priority' => static::portfolioConcernPriority('recovery_evidence', $recoveryState),
|
||||
'family' => 'recovery_evidence',
|
||||
'state' => $recoveryState,
|
||||
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($matchedConcerns === [] && $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST) {
|
||||
if ($backupAssessment instanceof TenantBackupHealthAssessment
|
||||
&& in_array($backupPosture, [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
], true)) {
|
||||
$matchedConcerns[] = [
|
||||
'priority' => static::portfolioConcernPriority('backup_health', $backupPosture),
|
||||
'family' => 'backup_health',
|
||||
'state' => $backupPosture,
|
||||
'reason' => $backupAssessment->primaryReason,
|
||||
];
|
||||
}
|
||||
|
||||
if (is_string($recoveryState)
|
||||
&& in_array($recoveryState, [
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||
], true)) {
|
||||
$matchedConcerns[] = [
|
||||
'priority' => static::portfolioConcernPriority('recovery_evidence', $recoveryState),
|
||||
'family' => 'recovery_evidence',
|
||||
'state' => $recoveryState,
|
||||
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedConcerns === []) {
|
||||
if ($selectedReviewRow === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($matchedConcerns, static fn (array $left, array $right): int => $left['priority'] <=> $right['priority']);
|
||||
|
||||
$primaryConcern = $matchedConcerns[0];
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return [
|
||||
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
||||
'tenantRouteKey' => filled($record->external_id) ? (string) $record->external_id : (string) $record->getKey(),
|
||||
'workspaceId' => $workspaceId,
|
||||
'concernFamily' => $primaryConcern['family'],
|
||||
'concernState' => $primaryConcern['state'],
|
||||
'concernReason' => $primaryConcern['reason'],
|
||||
'returnFilters' => static::portfolioReturnFilters($backupPostures, $recoveryEvidenceFilters, $triageSort),
|
||||
'concernFamily' => $selectedReviewRow['concern_family'],
|
||||
'concernState' => $selectedReviewRow['current_state'],
|
||||
'concernReason' => $selectedReviewRow['current_snapshot']['reasonCode'] ?? null,
|
||||
'returnFilters' => static::portfolioReturnFilters($backupPostures, $recoveryEvidenceFilters, $reviewStates, $triageSort),
|
||||
];
|
||||
}
|
||||
|
||||
@ -1074,6 +1118,7 @@ private static function portfolioArrivalStateForTenant(Tenant $record, array $tr
|
||||
public static function portfolioReturnFilters(
|
||||
array $backupPostures,
|
||||
array $recoveryEvidence,
|
||||
array $reviewStates,
|
||||
?string $triageSort,
|
||||
): array {
|
||||
$filters = [];
|
||||
@ -1086,6 +1131,10 @@ public static function portfolioReturnFilters(
|
||||
$filters['recovery_evidence'] = $recoveryEvidence;
|
||||
}
|
||||
|
||||
if ($reviewStates !== []) {
|
||||
$filters['review_state'] = $reviewStates;
|
||||
}
|
||||
|
||||
if ($triageSort !== null) {
|
||||
$filters['triage_sort'] = $triageSort;
|
||||
}
|
||||
@ -1102,6 +1151,7 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
|
||||
return static::portfolioReturnFilters(
|
||||
static::sanitizeBackupPostures($query['backup_posture'] ?? []),
|
||||
static::sanitizeRecoveryEvidenceStates($query['recovery_evidence'] ?? []),
|
||||
static::sanitizeReviewStates($query['review_state'] ?? []),
|
||||
static::sanitizeTriageSort($query['triage_sort'] ?? null),
|
||||
);
|
||||
}
|
||||
@ -1109,14 +1159,16 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
|
||||
private static function hasActivePortfolioTriageState(
|
||||
array $backupPostures,
|
||||
array $recoveryEvidence,
|
||||
array $reviewStates,
|
||||
?string $triageSort,
|
||||
): bool {
|
||||
return $backupPostures !== []
|
||||
|| $recoveryEvidence !== []
|
||||
|| $reviewStates !== []
|
||||
|| $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
||||
}
|
||||
|
||||
private static function portfolioConcernPriority(string $family, string $state): int
|
||||
public static function portfolioConcernPriority(string $family, string $state): int
|
||||
{
|
||||
return match (true) {
|
||||
$family === 'backup_health' && $state === TenantBackupHealthAssessment::POSTURE_ABSENT => 1,
|
||||
@ -1200,6 +1252,39 @@ private static function recoveryEvidenceState(mixed $livewire): array
|
||||
return static::sanitizeRecoveryEvidenceStates($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function reviewStateState(mixed $livewire): array
|
||||
{
|
||||
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$state = $livewire->getTableFilterState('review_state');
|
||||
$values = is_array($state) ? ($state['values'] ?? []) : [];
|
||||
|
||||
return static::sanitizeReviewStates($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* backup_posture: list<string>,
|
||||
* recovery_evidence: list<string>,
|
||||
* review_state: list<string>,
|
||||
* triage_sort: string|null
|
||||
* }
|
||||
*/
|
||||
public static function currentPortfolioTriageState(mixed $livewire): array
|
||||
{
|
||||
return [
|
||||
'backup_posture' => static::backupPostureState($livewire),
|
||||
'recovery_evidence' => static::recoveryEvidenceState($livewire),
|
||||
'review_state' => static::reviewStateState($livewire),
|
||||
'triage_sort' => static::triageSortState($livewire),
|
||||
];
|
||||
}
|
||||
|
||||
private static function triageSortIsWorstFirst(?string $value): bool
|
||||
{
|
||||
return $value === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
||||
@ -1218,6 +1303,414 @@ private static function recoveryEvidenceForTenant(Tenant $tenant): ?array
|
||||
return static::postureSnapshot()['recovery_evidence'][(int) $tenant->getKey()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function reviewStateOptions(): array
|
||||
{
|
||||
return collect(TenantTriageReview::DERIVED_STATES)
|
||||
->mapWithKeys(static fn (string $state): array => [
|
||||
$state => BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $state)->label,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private static function triageReviewSnapshot(): array
|
||||
{
|
||||
$request = request();
|
||||
$cached = $request->attributes->get(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY);
|
||||
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$snapshot = static::postureSnapshot();
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||
|
||||
if (! is_int($workspaceId) || $workspaceId <= 0) {
|
||||
$resolved = [
|
||||
'rows' => [],
|
||||
'summaries' => [],
|
||||
];
|
||||
|
||||
$request->attributes->set(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY, $resolved);
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$resolved = app(TenantTriageReviewStateResolver::class)->resolveMany(
|
||||
workspaceId: $workspaceId,
|
||||
tenantIds: $snapshot['tenant_ids'],
|
||||
backupHealthByTenant: $snapshot['backup_health'],
|
||||
recoveryEvidenceByTenant: $snapshot['recovery_evidence'],
|
||||
);
|
||||
|
||||
$request->attributes->set(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY, $resolved);
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function selectedTriageReviewRowForTenant(Tenant $tenant, array $triageState = []): ?array
|
||||
{
|
||||
$rows = static::triageReviewSnapshot()['rows'][(int) $tenant->getKey()] ?? null;
|
||||
|
||||
if (! is_array($rows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::selectedTriageReviewRow($rows, $triageState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_health?: array<string, mixed>,
|
||||
* recovery_evidence?: array<string, mixed>
|
||||
* } $rows
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function selectedTriageReviewRow(array $rows, array $triageState = []): ?array
|
||||
{
|
||||
$selectedFamily = static::selectedTriageReviewFamily($rows, $triageState);
|
||||
|
||||
if ($selectedFamily === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $rows[$selectedFamily] ?? null;
|
||||
|
||||
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row['concern_family'] = $selectedFamily;
|
||||
$row['concern_family_label'] = static::triageConcernFamilyLabel($selectedFamily);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_health?: array<string, mixed>,
|
||||
* recovery_evidence?: array<string, mixed>
|
||||
* } $rows
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
private static function selectedTriageReviewFamily(array $rows, array $triageState = []): ?string
|
||||
{
|
||||
$backupPostures = static::sanitizeBackupPostures($triageState['backup_posture'] ?? []);
|
||||
$recoveryEvidence = static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []);
|
||||
|
||||
if ($backupPostures !== [] && $recoveryEvidence === []) {
|
||||
return (($rows[PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['current_concern_present'] ?? false) === true)
|
||||
? PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
|
||||
: null;
|
||||
}
|
||||
|
||||
if ($backupPostures === [] && $recoveryEvidence !== []) {
|
||||
return (($rows[PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE]['current_concern_present'] ?? false) === true)
|
||||
? PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE
|
||||
: null;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
|
||||
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
|
||||
$row = $rows[$family] ?? null;
|
||||
|
||||
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentState = $row['current_state'] ?? null;
|
||||
|
||||
if (! is_string($currentState) || $currentState === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = [
|
||||
'family' => $family,
|
||||
'priority' => static::portfolioConcernPriority($family, $currentState),
|
||||
];
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static fn (array $left, array $right): int => $left['priority'] <=> $right['priority']);
|
||||
|
||||
return $candidates[0]['family'];
|
||||
}
|
||||
|
||||
private static function triageConcernFamilyLabel(string $concernFamily): string
|
||||
{
|
||||
return match ($concernFamily) {
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
|
||||
default => 'Portfolio concern',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
private static function triageReviewDescriptionForTenant(Tenant $tenant, array $triageState = []): ?string
|
||||
{
|
||||
$row = static::selectedTriageReviewRowForTenant($tenant, $triageState);
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [$row['concern_family_label']];
|
||||
|
||||
if (filled($row['reviewed_by_user_name'] ?? null)) {
|
||||
$parts[] = 'by '.(string) $row['reviewed_by_user_name'];
|
||||
}
|
||||
|
||||
if (($row['reviewed_at'] ?? null) instanceof \Illuminate\Support\Carbon) {
|
||||
$parts[] = $row['reviewed_at']->diffForHumans();
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
private static function triageReviewActionModalDescription(
|
||||
Tenant $tenant,
|
||||
array $triageState,
|
||||
string $targetManualState,
|
||||
): string {
|
||||
$row = static::selectedActionTriageReviewRowForTenant($tenant, $triageState);
|
||||
|
||||
if ($row === null) {
|
||||
return 'This triage slice no longer points at a current visible concern.';
|
||||
}
|
||||
|
||||
$currentLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $row['derived_state'])->label;
|
||||
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
|
||||
|
||||
return implode("\n\n", [
|
||||
'Concern family: '.$row['concern_family_label'],
|
||||
'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 static function userCanSeeTriageReviewAction(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->isMember($user, $tenant);
|
||||
}
|
||||
|
||||
private static function triageReviewActionIsDisabled(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $resolver->can($user, $tenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE);
|
||||
}
|
||||
|
||||
private static function triageReviewActionTooltip(Tenant $tenant): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if ($resolver->isMember($user, $tenant) && ! $resolver->can($user, $tenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)) {
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function authorizeTriageReviewAction(Tenant $tenant): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
private static function handleTriageReviewMutation(
|
||||
Tenant $tenant,
|
||||
array $triageState,
|
||||
string $targetManualState,
|
||||
TenantTriageReviewService $service,
|
||||
): void {
|
||||
$row = static::selectedActionTriageReviewRowForTenant($tenant, $triageState);
|
||||
|
||||
if ($row === null) {
|
||||
Notification::make()
|
||||
->title('No current concern to update')
|
||||
->body('This tenant no longer belongs to the current triage slice.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
$backupHealth = static::backupHealthAssessmentForTenant($tenant);
|
||||
$recoveryEvidence = static::recoveryEvidenceForTenant($tenant);
|
||||
|
||||
$review = match ($targetManualState) {
|
||||
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
|
||||
tenant: $tenant,
|
||||
concernFamily: (string) $row['concern_family'],
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
),
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
|
||||
tenant: $tenant,
|
||||
concernFamily: (string) $row['concern_family'],
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $review instanceof TenantTriageReview) {
|
||||
return;
|
||||
}
|
||||
|
||||
request()->attributes->remove(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY);
|
||||
|
||||
Notification::make()
|
||||
->title('Review state updated')
|
||||
->body(sprintf(
|
||||
'%s is now %s for %s.',
|
||||
$tenant->name,
|
||||
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
|
||||
static::triageConcernFamilyLabel((string) $review->concern_family),
|
||||
))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function selectedActionTriageReviewRowForTenant(Tenant $tenant, array $triageState = []): ?array
|
||||
{
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
|
||||
if ($tenantId <= 0 || $workspaceId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
$rows = app(TenantTriageReviewStateResolver::class)->resolveMany(
|
||||
workspaceId: $workspaceId,
|
||||
tenantIds: [$tenantId],
|
||||
backupHealthByTenant: [$tenantId => $backupHealth],
|
||||
recoveryEvidenceByTenant: [$tenantId => $recoveryEvidence],
|
||||
)['rows'][$tenantId] ?? null;
|
||||
|
||||
if (! is_array($rows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::selectedTriageReviewRow($rows, $triageState);
|
||||
}
|
||||
|
||||
private static function applySnapshotTenantSubsetFilter(
|
||||
Builder $query,
|
||||
array $selectedValues,
|
||||
@ -1266,6 +1759,48 @@ private static function applyRecoveryEvidenceFilter(Builder $query, array $selec
|
||||
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $selectedValues
|
||||
* @param array{
|
||||
* backup_posture?: list<string>,
|
||||
* recovery_evidence?: list<string>,
|
||||
* review_state?: list<string>,
|
||||
* triage_sort?: string|null
|
||||
* } $triageState
|
||||
*/
|
||||
private static function applyReviewStateFilter(Builder $query, array $selectedValues, array $triageState): Builder
|
||||
{
|
||||
if ($selectedValues === []) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$tenantIds = collect(static::triageReviewSnapshot()['rows'])
|
||||
->map(function (mixed $rows, mixed $tenantId) use ($selectedValues, $triageState): ?int {
|
||||
if (! is_array($rows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = static::selectedTriageReviewRow($rows, $triageState);
|
||||
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return in_array((string) ($row['derived_state'] ?? ''), $selectedValues, true)
|
||||
? (int) $tenantId
|
||||
: null;
|
||||
})
|
||||
->filter(static fn (?int $tenantId): bool => is_int($tenantId))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
|
||||
}
|
||||
|
||||
private static function applyWorstFirstTriageOrdering(Builder $query): Builder
|
||||
{
|
||||
$tiers = static::postureSnapshot()['triage_tiers'];
|
||||
@ -2084,10 +2619,12 @@ public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
* @return array{
|
||||
* state:string,
|
||||
* cta_url:string,
|
||||
* lifecycle:?string,
|
||||
* is_enabled:?bool,
|
||||
* display_name:?string,
|
||||
* provider:?string,
|
||||
* status:?string,
|
||||
* health_status:?string,
|
||||
* consent_status:?string,
|
||||
* verification_status:?string,
|
||||
* last_health_check_at:?string,
|
||||
* last_error_reason_code:?string
|
||||
* }
|
||||
@ -2118,10 +2655,10 @@ private static function providerConnectionState(Tenant $tenant): array
|
||||
'needs_default_connection' => false,
|
||||
'display_name' => null,
|
||||
'provider' => null,
|
||||
'lifecycle' => null,
|
||||
'is_enabled' => null,
|
||||
'consent_status' => null,
|
||||
'verification_status' => null,
|
||||
'status' => null,
|
||||
'health_status' => null,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => null,
|
||||
];
|
||||
@ -2133,14 +2670,14 @@ private static function providerConnectionState(Tenant $tenant): array
|
||||
'needs_default_connection' => ! $connection->is_default,
|
||||
'display_name' => (string) $connection->display_name,
|
||||
'provider' => (string) $connection->provider,
|
||||
'lifecycle' => (bool) $connection->is_enabled ? 'enabled' : 'disabled',
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
'consent_status' => $connection->consent_status instanceof BackedEnum
|
||||
? (string) $connection->consent_status->value
|
||||
: (is_string($connection->consent_status) ? $connection->consent_status : null),
|
||||
'verification_status' => $connection->verification_status instanceof BackedEnum
|
||||
? (string) $connection->verification_status->value
|
||||
: (is_string($connection->verification_status) ? $connection->verification_status : null),
|
||||
'status' => is_string($connection->status) ? $connection->status : null,
|
||||
'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
|
||||
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
|
||||
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
|
||||
];
|
||||
|
||||
@ -56,7 +56,7 @@ protected function getTableEmptyStateHeading(): ?string
|
||||
protected function getTableEmptyStateDescription(): ?string
|
||||
{
|
||||
if ($this->hasActiveTriageEmptyState()) {
|
||||
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.';
|
||||
return 'Try a different backup posture, recovery evidence, or review-state filter, or return to the default calm-browsing order.';
|
||||
}
|
||||
|
||||
return parent::getTableEmptyStateDescription();
|
||||
@ -85,6 +85,7 @@ private function applyRequestedTriageIntent(): void
|
||||
{
|
||||
$hasIntent = request()->query->has('backup_posture')
|
||||
|| request()->query->has('recovery_evidence')
|
||||
|| request()->query->has('review_state')
|
||||
|| request()->query->has('triage_sort');
|
||||
|
||||
if (! $hasIntent) {
|
||||
@ -93,9 +94,10 @@ private function applyRequestedTriageIntent(): void
|
||||
|
||||
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
|
||||
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
|
||||
$reviewStates = TenantResource::sanitizeReviewStates(request()->query('review_state'));
|
||||
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
|
||||
|
||||
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) {
|
||||
foreach (['backup_posture', 'recovery_evidence', 'review_state', 'triage_sort'] as $filterName) {
|
||||
data_forget($this->tableFilters, $filterName);
|
||||
data_forget($this->tableDeferredFilters, $filterName);
|
||||
}
|
||||
@ -110,6 +112,11 @@ private function applyRequestedTriageIntent(): void
|
||||
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
|
||||
}
|
||||
|
||||
if ($reviewStates !== []) {
|
||||
$this->tableFilters['review_state']['values'] = $reviewStates;
|
||||
$this->tableDeferredFilters['review_state']['values'] = $reviewStates;
|
||||
}
|
||||
|
||||
if ($triageSort !== null) {
|
||||
$this->tableFilters['triage_sort']['value'] = $triageSort;
|
||||
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
|
||||
@ -122,17 +129,19 @@ private function hasActiveTriageEmptyState(): bool
|
||||
|
||||
return $state['backup_posture'] !== []
|
||||
|| $state['recovery_evidence'] !== []
|
||||
|| $state['review_state'] !== []
|
||||
|| $state['triage_sort'] === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, triage_sort: string|null}
|
||||
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, review_state: list<string>, triage_sort: string|null}
|
||||
*/
|
||||
public function currentPortfolioTriageReturnState(): array
|
||||
{
|
||||
return [
|
||||
'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.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')),
|
||||
];
|
||||
}
|
||||
|
||||
@ -68,8 +68,14 @@ public function table(Table $table): Table
|
||||
return Tenant::query()
|
||||
->with('workspace')
|
||||
->withCount([
|
||||
'providerConnections',
|
||||
'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'),
|
||||
'providerConnections as critical_provider_connections_count' => fn (Builder $query): Builder => $query
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->whereIn('verification_status', ['blocked', 'error']),
|
||||
'providerConnections as warning_provider_connections_count' => fn (Builder $query): Builder => $query
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->where('verification_status', 'degraded'),
|
||||
'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'),
|
||||
]);
|
||||
})
|
||||
@ -108,11 +114,14 @@ private function healthForTenant(Tenant $tenant): string
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) {
|
||||
if ((int) ($tenant->getAttribute('critical_provider_connections_count') ?? 0) > 0) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ((int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0) {
|
||||
if (
|
||||
(int) ($tenant->getAttribute('warning_provider_connections_count') ?? 0) > 0
|
||||
|| (int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0
|
||||
) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
|
||||
@ -50,7 +50,17 @@ public function providerConnections(): Collection
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('provider')
|
||||
->get(['id', 'provider', 'status', 'health_status', 'is_default', 'last_health_check_at']);
|
||||
->get([
|
||||
'id',
|
||||
'display_name',
|
||||
'provider',
|
||||
'is_default',
|
||||
'is_enabled',
|
||||
'consent_status',
|
||||
'verification_status',
|
||||
'last_health_check_at',
|
||||
'last_error_reason_code',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -5,18 +5,51 @@
|
||||
namespace App\Filament\Widgets\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\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\Notifications\Notification;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantTriageArrivalContinuity extends Widget
|
||||
class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSchemas
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $arrivalState = null;
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
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>
|
||||
*/
|
||||
@ -25,11 +58,210 @@ protected function getViewData(): array
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return ['context' => null];
|
||||
return ['context' => null, 'reviewState' => null];
|
||||
}
|
||||
|
||||
$context = $this->resolveArrivalContext($tenant);
|
||||
|
||||
if ($context === null) {
|
||||
return ['context' => null, 'reviewState' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'context' => app(PortfolioArrivalContextResolver::class)->resolve(request(), $tenant),
|
||||
'context' => $context,
|
||||
'reviewState' => $this->currentReviewStateFor($tenant, $context->concernFamily),
|
||||
];
|
||||
}
|
||||
|
||||
public function markReviewedAction(): Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Action::make('markReviewed')
|
||||
->label('Mark reviewed')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Mark reviewed')
|
||||
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_REVIEWED))
|
||||
->visible(fn (): bool => $this->canShowReviewActions())
|
||||
->action(function (TenantTriageReviewService $service): void {
|
||||
$this->handleReviewMutation(TenantTriageReview::STATE_REVIEWED, $service);
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
public function markFollowUpNeededAction(): Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Action::make('markFollowUpNeeded')
|
||||
->label('Mark follow-up needed')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Mark follow-up needed')
|
||||
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_FOLLOW_UP_NEEDED))
|
||||
->visible(fn (): bool => $this->canShowReviewActions())
|
||||
->action(function (TenantTriageReviewService $service): void {
|
||||
$this->handleReviewMutation(TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $service);
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function canShowReviewActions(): bool
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = $this->resolveArrivalContext($tenant);
|
||||
|
||||
if ($context === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($this->currentReviewStateFor($tenant, $context->concernFamily)['current_concern_present'] ?? false) === true;
|
||||
}
|
||||
|
||||
private function reviewModalDescription(string $targetManualState): \Closure
|
||||
{
|
||||
return function () use ($targetManualState): string {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 'This triage session is no longer available.';
|
||||
}
|
||||
|
||||
$context = $this->resolveArrivalContext($tenant);
|
||||
|
||||
if ($context === null) {
|
||||
return 'This triage session is no longer available.';
|
||||
}
|
||||
|
||||
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
|
||||
|
||||
if (($reviewState['current_concern_present'] ?? false) !== true) {
|
||||
return 'This triage session no longer points at a current concern.';
|
||||
}
|
||||
|
||||
$currentLabel = BadgeRenderer::spec(
|
||||
BadgeDomain::TenantTriageReviewState,
|
||||
(string) ($reviewState['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED),
|
||||
)->label;
|
||||
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
|
||||
|
||||
return implode("\n\n", [
|
||||
'Concern family: '.$this->concernFamilyLabel($context->concernFamily),
|
||||
'Current review state: '.$currentLabel,
|
||||
'Target state: '.$targetLabel,
|
||||
'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.',
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
private function handleReviewMutation(string $targetManualState, TenantTriageReviewService $service): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = $this->resolveArrivalContext($tenant);
|
||||
|
||||
if ($context === null) {
|
||||
Notification::make()
|
||||
->title('No triage session available')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
|
||||
|
||||
if (($reviewState['current_concern_present'] ?? false) !== true) {
|
||||
Notification::make()
|
||||
->title('No current concern to update')
|
||||
->body('This arrival context no longer maps to an active concern.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
$actor = auth()->user();
|
||||
|
||||
$review = match ($targetManualState) {
|
||||
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
|
||||
tenant: $tenant,
|
||||
concernFamily: $context->concernFamily,
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
),
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
|
||||
tenant: $tenant,
|
||||
concernFamily: $context->concernFamily,
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $review instanceof TenantTriageReview) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Review state updated')
|
||||
->body(sprintf(
|
||||
'%s is now %s for %s.',
|
||||
$tenant->name,
|
||||
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
|
||||
$this->concernFamilyLabel($context->concernFamily),
|
||||
))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
|
||||
{
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
return app(TenantTriageReviewStateResolver::class)->resolveMany(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantIds: [(int) $tenant->getKey()],
|
||||
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth],
|
||||
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence],
|
||||
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null;
|
||||
}
|
||||
|
||||
private function concernFamilyLabel(string $concernFamily): string
|
||||
{
|
||||
return match ($concernFamily) {
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
|
||||
default => 'Portfolio concern',
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
|
||||
{
|
||||
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState);
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,11 @@ class WorkspaceNeedsAttention extends Widget
|
||||
*/
|
||||
public array $emptyState = [];
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
public array $triageReviewProgress = [];
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* key: string,
|
||||
@ -71,10 +76,12 @@ class WorkspaceNeedsAttention extends Widget
|
||||
* action_label: string,
|
||||
* action_url: string
|
||||
* } $emptyState
|
||||
* @param array<int, array<string, mixed>> $triageReviewProgress
|
||||
*/
|
||||
public function mount(array $items = [], array $emptyState = []): void
|
||||
public function mount(array $items = [], array $emptyState = [], array $triageReviewProgress = []): void
|
||||
{
|
||||
$this->items = $items;
|
||||
$this->emptyState = $emptyState;
|
||||
$this->triageReviewProgress = $triageReviewProgress;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
@ -144,11 +143,6 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
|
||||
default => ProviderConsentStatus::Required,
|
||||
};
|
||||
$verificationStatus = ProviderVerificationStatus::Unknown;
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
$reasonCode = match ($status) {
|
||||
'ok' => null,
|
||||
'error' => ProviderReasonCodes::ProviderAuthFailed,
|
||||
@ -164,19 +158,18 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
|
||||
[
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => $consentStatus->value,
|
||||
'consent_granted_at' => $status === 'ok' ? now() : null,
|
||||
'consent_last_checked_at' => now(),
|
||||
'consent_error_code' => $reasonCode,
|
||||
'consent_error_message' => $error,
|
||||
'consent_error_code' => $status === 'error' ? $reasonCode : null,
|
||||
'consent_error_message' => $status === 'error' ? $error : null,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => $reasonCode,
|
||||
'last_error_message' => $error,
|
||||
'last_error_message' => $status === 'ok' ? null : $error,
|
||||
'is_default' => $hasDefault ? false : true,
|
||||
],
|
||||
);
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\AdminConsentUrlFactory;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
@ -100,12 +99,6 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
|
||||
->where('is_default', true)
|
||||
->exists();
|
||||
|
||||
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection = ProviderConnection::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -115,15 +108,14 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
|
||||
[
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => $projectedState['status'],
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
|
||||
@ -385,8 +385,6 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
|
||||
$connection->update([
|
||||
'consent_status' => $projected['consent_status'],
|
||||
'verification_status' => $projected['verification_status'],
|
||||
'status' => $projected['status'],
|
||||
'health_status' => $projected['health_status'],
|
||||
'last_health_check_at' => now(),
|
||||
'last_error_reason_code' => $projected['last_error_reason_code'],
|
||||
'last_error_message' => $projected['last_error_message'],
|
||||
@ -449,12 +447,11 @@ private function logVerificationResult(
|
||||
'metadata' => [
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'connection_type' => $connection->connection_type?->value ?? $connection->connection_type,
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
'consent_status' => $connection->consent_status?->value ?? $connection->consent_status,
|
||||
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
|
||||
'credential_source' => $identity->credentialSource,
|
||||
'effective_client_id' => $identity->effectiveClientId,
|
||||
'status' => $connection->status,
|
||||
'health_status' => $connection->health_status,
|
||||
'reason_code' => $reasonCode,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'previous_consent_status' => $previousConsentStatus,
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -44,7 +45,10 @@ public function handle(
|
||||
// FR-018: Skip tenants without active provider connection
|
||||
$hasConnection = ProviderConnection::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', 'connected')
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->where('is_enabled', true)
|
||||
->where('consent_status', ProviderConsentStatus::Granted->value)
|
||||
->exists();
|
||||
|
||||
if (! $hasConnection) {
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@ -37,4 +38,30 @@ public function assignedByUser(): BelongsTo
|
||||
{
|
||||
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,6 +274,18 @@ public function scopeOpenDrift(Builder $query): Builder
|
||||
->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
|
||||
{
|
||||
return $query
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -89,6 +90,17 @@ public function scopeTerminalFailure(Builder $query): Builder
|
||||
->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
|
||||
{
|
||||
$policy ??= app(OperationLifecyclePolicy::class);
|
||||
@ -284,6 +296,34 @@ public function isGovernanceArtifactOperation(): bool
|
||||
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
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
|
||||
@ -21,6 +21,7 @@ class ProviderConnection extends Model
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'is_enabled' => 'boolean',
|
||||
'connection_type' => ProviderConnectionType::class,
|
||||
'consent_status' => ProviderConsentStatus::class,
|
||||
'consent_granted_at' => 'datetime',
|
||||
@ -151,7 +152,6 @@ public function requiresMigrationReview(): bool
|
||||
*/
|
||||
public function classificationProjection(
|
||||
\App\Services\Providers\ProviderConnectionClassificationResult $result,
|
||||
\App\Services\Providers\ProviderConnectionStateProjector $stateProjector,
|
||||
): array {
|
||||
$metadata = array_merge(
|
||||
is_array($this->metadata) ? $this->metadata : [],
|
||||
@ -166,17 +166,8 @@ public function classificationProjection(
|
||||
];
|
||||
|
||||
if ($result->reviewRequired) {
|
||||
$statusProjection = $stateProjector->project(
|
||||
connectionType: $result->suggestedConnectionType,
|
||||
consentStatus: $this->consent_status,
|
||||
verificationStatus: ProviderVerificationStatus::Blocked,
|
||||
currentStatus: is_string($this->status) ? $this->status : null,
|
||||
);
|
||||
|
||||
return $projection + [
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'status' => $statusProjection['status'],
|
||||
'health_status' => $statusProjection['health_status'],
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||
'last_error_message' => 'Legacy provider connection requires explicit migration review.',
|
||||
'migration_reviewed_at' => null,
|
||||
@ -197,19 +188,10 @@ public function classificationProjection(
|
||||
$this->migration_review_required
|
||||
|| $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
|
||||
) {
|
||||
$statusProjection = $stateProjector->project(
|
||||
connectionType: $result->suggestedConnectionType,
|
||||
consentStatus: $this->consent_status,
|
||||
verificationStatus: $currentVerificationStatus,
|
||||
currentStatus: is_string($this->status) ? $this->status : null,
|
||||
);
|
||||
|
||||
return $projection + [
|
||||
'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus
|
||||
? $currentVerificationStatus->value
|
||||
: $currentVerificationStatus,
|
||||
'status' => $statusProjection['status'],
|
||||
'health_status' => $statusProjection['health_status'],
|
||||
'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
|
||||
? null
|
||||
: $currentReasonCode,
|
||||
|
||||
134
apps/platform/app/Models/TenantTriageReview.php
Normal file
134
apps/platform/app/Models/TenantTriageReview.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantTriageReview extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATE_REVIEWED = 'reviewed';
|
||||
|
||||
public const STATE_FOLLOW_UP_NEEDED = 'follow_up_needed';
|
||||
|
||||
public const DERIVED_STATE_NOT_REVIEWED = 'not_reviewed';
|
||||
|
||||
public const DERIVED_STATE_CHANGED_SINCE_REVIEW = 'changed_since_review';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const ACTIVE_CONCERN_FAMILIES = [
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const MANUAL_STATES = [
|
||||
self::STATE_REVIEWED,
|
||||
self::STATE_FOLLOW_UP_NEEDED,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const DERIVED_STATES = [
|
||||
self::DERIVED_STATE_NOT_REVIEWED,
|
||||
self::STATE_REVIEWED,
|
||||
self::STATE_FOLLOW_UP_NEEDED,
|
||||
self::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'review_snapshot' => 'array',
|
||||
'reviewed_at' => 'datetime',
|
||||
'last_seen_matching_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewed_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForConcernFamily(Builder $query, string $concernFamily): Builder
|
||||
{
|
||||
return $query->where('concern_family', $concernFamily);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('resolved_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeResolved(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('resolved_at');
|
||||
}
|
||||
}
|
||||
@ -79,8 +79,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(CapabilityResolver::class);
|
||||
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
||||
$this->app->scoped(CapabilityResolver::class);
|
||||
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ class RoleCapabilityMap
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
@ -94,6 +95,7 @@ class RoleCapabilityMap
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
@ -121,6 +123,7 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
@ -28,6 +29,7 @@ public function __construct(
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -47,12 +49,34 @@ public function startCompare(
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||
}
|
||||
|
||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||
$profile = $assignment->baselineProfile;
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
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);
|
||||
|
||||
if ($precondition !== null) {
|
||||
@ -124,6 +148,103 @@ public function startCompare(
|
||||
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
|
||||
{
|
||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PortfolioTriage;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class TenantTriageReviewService
|
||||
{
|
||||
public function __construct(
|
||||
private TenantTriageReviewFingerprint $fingerprints,
|
||||
private WorkspaceAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public function markReviewed(
|
||||
Tenant $tenant,
|
||||
string $concernFamily,
|
||||
?TenantBackupHealthAssessment $backupHealth = null,
|
||||
?array $recoveryEvidence = null,
|
||||
?User $actor = null,
|
||||
): TenantTriageReview {
|
||||
return $this->store(
|
||||
tenant: $tenant,
|
||||
concernFamily: $concernFamily,
|
||||
manualState: TenantTriageReview::STATE_REVIEWED,
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
actor: $actor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
public function markFollowUpNeeded(
|
||||
Tenant $tenant,
|
||||
string $concernFamily,
|
||||
?TenantBackupHealthAssessment $backupHealth = null,
|
||||
?array $recoveryEvidence = null,
|
||||
?User $actor = null,
|
||||
): TenantTriageReview {
|
||||
return $this->store(
|
||||
tenant: $tenant,
|
||||
concernFamily: $concernFamily,
|
||||
manualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
backupHealth: $backupHealth,
|
||||
recoveryEvidence: $recoveryEvidence,
|
||||
actor: $actor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
*/
|
||||
private function store(
|
||||
Tenant $tenant,
|
||||
string $concernFamily,
|
||||
string $manualState,
|
||||
?TenantBackupHealthAssessment $backupHealth,
|
||||
?array $recoveryEvidence,
|
||||
?User $actor,
|
||||
): TenantTriageReview {
|
||||
if (! in_array($manualState, TenantTriageReview::MANUAL_STATES, true)) {
|
||||
throw new InvalidArgumentException('Unsupported triage review state.');
|
||||
}
|
||||
|
||||
if (! is_numeric($tenant->workspace_id) || (int) $tenant->workspace_id <= 0) {
|
||||
throw new InvalidArgumentException('Tenant must belong to a workspace.');
|
||||
}
|
||||
|
||||
$currentConcern = $this->fingerprints->forConcernFamily($concernFamily, $backupHealth, $recoveryEvidence);
|
||||
|
||||
if ($currentConcern === null) {
|
||||
throw new InvalidArgumentException('No current triage concern is available for review.');
|
||||
}
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
$now = now();
|
||||
|
||||
/** @var TenantTriageReview $review */
|
||||
$review = DB::transaction(function () use (
|
||||
$tenant,
|
||||
$workspaceId,
|
||||
$manualState,
|
||||
$currentConcern,
|
||||
$actor,
|
||||
$now,
|
||||
): TenantTriageReview {
|
||||
TenantTriageReview::query()
|
||||
->forWorkspace($workspaceId)
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->where('concern_family', $currentConcern['concern_family'])
|
||||
->active()
|
||||
->update([
|
||||
'resolved_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return TenantTriageReview::query()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'concern_family' => $currentConcern['concern_family'],
|
||||
'current_state' => $manualState,
|
||||
'reviewed_at' => $now,
|
||||
'reviewed_by_user_id' => $actor?->getKey(),
|
||||
'review_fingerprint' => $currentConcern['fingerprint'],
|
||||
'review_snapshot' => $currentConcern['snapshot'],
|
||||
'last_seen_matching_at' => $now,
|
||||
'resolved_at' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
$review->loadMissing('reviewer');
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: $manualState === TenantTriageReview::STATE_REVIEWED
|
||||
? AuditActionId::TenantTriageReviewMarkedReviewed
|
||||
: AuditActionId::TenantTriageReviewMarkedFollowUpNeeded,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'concern_family' => $currentConcern['concern_family'],
|
||||
'concern_state' => $currentConcern['concern_state'],
|
||||
'reason_code' => $currentConcern['snapshot']['reasonCode'] ?? null,
|
||||
'review_state' => $manualState,
|
||||
],
|
||||
],
|
||||
actor: $actor,
|
||||
resourceType: 'tenant_triage_review',
|
||||
resourceId: (string) $review->getKey(),
|
||||
targetLabel: $tenant->name,
|
||||
tenant: $tenant,
|
||||
summary: $this->summaryFor($currentConcern['concern_family'], $manualState),
|
||||
);
|
||||
|
||||
return $review;
|
||||
}
|
||||
|
||||
private function summaryFor(string $concernFamily, string $manualState): string
|
||||
{
|
||||
$family = match ($concernFamily) {
|
||||
'backup_health' => 'Backup health',
|
||||
'recovery_evidence' => 'Recovery evidence',
|
||||
default => 'Portfolio concern',
|
||||
};
|
||||
|
||||
$state = $manualState === TenantTriageReview::STATE_REVIEWED
|
||||
? 'reviewed'
|
||||
: 'follow-up needed';
|
||||
|
||||
return sprintf('%s marked %s', $family, $state);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Services\Providers\Contracts;
|
||||
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
|
||||
final class HealthResult
|
||||
{
|
||||
/**
|
||||
@ -9,8 +11,7 @@ final class HealthResult
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly bool $healthy,
|
||||
public readonly string $status,
|
||||
public readonly string $healthStatus,
|
||||
public readonly string $verificationStatus,
|
||||
public readonly ?string $reasonCode = null,
|
||||
public readonly ?string $message = null,
|
||||
public readonly array $meta = [],
|
||||
@ -19,9 +20,9 @@ public function __construct(
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public static function ok(string $status = 'connected', string $healthStatus = 'ok', array $meta = []): self
|
||||
public static function ok(array $meta = []): self
|
||||
{
|
||||
return new self(true, $status, $healthStatus, null, null, $meta);
|
||||
return new self(true, ProviderVerificationStatus::Healthy->value, null, null, $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -30,10 +31,9 @@ public static function ok(string $status = 'connected', string $healthStatus = '
|
||||
public static function failed(
|
||||
string $reasonCode,
|
||||
string $message,
|
||||
string $status = 'error',
|
||||
string $healthStatus = 'down',
|
||||
string $verificationStatus = 'error',
|
||||
array $meta = [],
|
||||
): self {
|
||||
return new self(false, $status, $healthStatus, $reasonCode, $message, $meta);
|
||||
return new self(false, $verificationStatus, $reasonCode, $message, $meta);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Services\Providers\Contracts\ProviderHealthCheck;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Throwable;
|
||||
|
||||
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
|
||||
@ -25,15 +26,12 @@ public function check(ProviderConnection $connection): HealthResult
|
||||
return HealthResult::failed(
|
||||
reasonCode: $reasonCode,
|
||||
message: $message !== '' ? $message : 'Health check failed.',
|
||||
status: $this->statusForReason($reasonCode),
|
||||
healthStatus: $this->healthForReason($reasonCode),
|
||||
verificationStatus: $this->verificationStatusForReason($reasonCode),
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->successful()) {
|
||||
return HealthResult::ok(
|
||||
status: 'connected',
|
||||
healthStatus: 'ok',
|
||||
meta: [
|
||||
'organization_id' => $response->data['id'] ?? null,
|
||||
'organization_display_name' => $response->data['displayName'] ?? null,
|
||||
@ -47,8 +45,7 @@ public function check(ProviderConnection $connection): HealthResult
|
||||
return HealthResult::failed(
|
||||
reasonCode: $reasonCode,
|
||||
message: $message !== '' ? $message : 'Health check failed.',
|
||||
status: $this->statusForReason($reasonCode),
|
||||
healthStatus: $this->healthForReason($reasonCode),
|
||||
verificationStatus: $this->verificationStatusForReason($reasonCode),
|
||||
meta: [
|
||||
'http_status' => $response->status,
|
||||
],
|
||||
@ -89,24 +86,14 @@ private function messageForResponse(GraphResponse $response): string
|
||||
return 'Health check failed.';
|
||||
}
|
||||
|
||||
private function statusForReason(string $reasonCode): string
|
||||
private function verificationStatusForReason(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::ProviderAuthFailed,
|
||||
ProviderReasonCodes::ProviderPermissionDenied,
|
||||
ProviderReasonCodes::ProviderConsentMissing => 'needs_consent',
|
||||
default => 'error',
|
||||
};
|
||||
}
|
||||
|
||||
private function healthForReason(string $reasonCode): string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
ProviderReasonCodes::RateLimited => 'degraded',
|
||||
ProviderReasonCodes::NetworkUnreachable,
|
||||
ProviderReasonCodes::ProviderAuthFailed,
|
||||
ProviderReasonCodes::ProviderPermissionDenied => 'down',
|
||||
default => 'down',
|
||||
ProviderReasonCodes::RateLimited => ProviderVerificationStatus::Degraded->value,
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
ProviderReasonCodes::ProviderConsentRevoked => ProviderVerificationStatus::Blocked->value,
|
||||
default => ProviderVerificationStatus::Error->value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,9 +50,9 @@ public function classify(
|
||||
'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null,
|
||||
'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false),
|
||||
'current_connection_type' => $currentConnectionType,
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
'consent_status' => $this->enumValue($connection->consent_status),
|
||||
'verification_status' => $this->enumValue($connection->verification_status),
|
||||
'status' => is_string($connection->status) ? $connection->status : null,
|
||||
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
|
||||
],
|
||||
effectiveApp: $this->effectiveAppMetadata(
|
||||
|
||||
@ -15,10 +15,7 @@
|
||||
|
||||
final class ProviderConnectionMutationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CredentialManager $credentials,
|
||||
private readonly ProviderConnectionStateProjector $stateProjector,
|
||||
) {}
|
||||
public function __construct(private readonly CredentialManager $credentials) {}
|
||||
|
||||
public function enableDedicatedOverride(
|
||||
ProviderConnection $connection,
|
||||
@ -50,15 +47,10 @@ public function enableDedicatedOverride(
|
||||
: $this->normalizeConsentStatus($connection->consent_status);
|
||||
$verificationStatus = ProviderVerificationStatus::Unknown;
|
||||
|
||||
$updates = $this->projectConnectionState(
|
||||
connection: $connection,
|
||||
connectionType: ProviderConnectionType::Dedicated,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
|
||||
$connection->forceFill(array_merge($updates, [
|
||||
$connection->forceFill([
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'consent_status' => $consentStatus->value,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => $needsConsentReset
|
||||
? ProviderReasonCodes::ProviderConsentMissing
|
||||
@ -67,9 +59,9 @@ public function enableDedicatedOverride(
|
||||
'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []),
|
||||
'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at,
|
||||
'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at,
|
||||
'consent_error_code' => $needsConsentReset ? null : $connection->consent_error_code,
|
||||
'consent_error_message' => $needsConsentReset ? null : $connection->consent_error_message,
|
||||
]))->save();
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
])->save();
|
||||
|
||||
$this->credentials->upsertClientSecretCredential(
|
||||
connection: $connection->fresh(),
|
||||
@ -90,15 +82,9 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec
|
||||
$connection->credential->delete();
|
||||
}
|
||||
|
||||
$updates = $this->projectConnectionState(
|
||||
connection: $connection,
|
||||
connectionType: ProviderConnectionType::Platform,
|
||||
consentStatus: ProviderConsentStatus::Required,
|
||||
verificationStatus: ProviderVerificationStatus::Unknown,
|
||||
);
|
||||
|
||||
$connection->forceFill(array_merge($updates, [
|
||||
$connection->forceFill([
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
@ -108,7 +94,7 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
||||
'last_error_message' => null,
|
||||
'scopes_granted' => [],
|
||||
]))->save();
|
||||
])->save();
|
||||
|
||||
return $connection->fresh(['credential']);
|
||||
});
|
||||
@ -126,47 +112,19 @@ public function deleteDedicatedCredential(ProviderConnection $connection): Provi
|
||||
$consentStatus = $this->normalizeConsentStatus($connection->consent_status);
|
||||
$verificationStatus = ProviderVerificationStatus::Blocked;
|
||||
|
||||
$updates = $this->projectConnectionState(
|
||||
connection: $connection,
|
||||
connectionType: ProviderConnectionType::Dedicated,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
);
|
||||
|
||||
$connection->forceFill(array_merge($updates, [
|
||||
$connection->forceFill([
|
||||
'connection_type' => ProviderConnectionType::Dedicated->value,
|
||||
'consent_status' => $consentStatus->value,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing,
|
||||
'last_error_message' => 'Dedicated credential is missing.',
|
||||
]))->save();
|
||||
])->save();
|
||||
|
||||
return $connection->fresh(['credential']);
|
||||
});
|
||||
}
|
||||
|
||||
private function projectConnectionState(
|
||||
ProviderConnection $connection,
|
||||
ProviderConnectionType $connectionType,
|
||||
ProviderConsentStatus $consentStatus,
|
||||
ProviderVerificationStatus $verificationStatus,
|
||||
): array {
|
||||
$projected = $this->stateProjector->project(
|
||||
connectionType: $connectionType,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
|
||||
return [
|
||||
'consent_status' => $consentStatus->value,
|
||||
'verification_status' => $verificationStatus->value,
|
||||
'status' => $projected['status'],
|
||||
'health_status' => $projected['health_status'],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConsentStatus(
|
||||
ProviderConsentStatus|string|null $consentStatus,
|
||||
): ProviderConsentStatus {
|
||||
|
||||
@ -54,7 +54,7 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
|
||||
);
|
||||
}
|
||||
|
||||
if ((string) $connection->status === 'disabled') {
|
||||
if (! (bool) $connection->is_enabled) {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||
'Provider connection is disabled.',
|
||||
@ -95,39 +95,30 @@ private function consentBlocker(ProviderConnection $connection): ?ProviderConnec
|
||||
{
|
||||
$consentStatus = $connection->consent_status;
|
||||
|
||||
if ($consentStatus instanceof ProviderConsentStatus) {
|
||||
return match ($consentStatus) {
|
||||
ProviderConsentStatus::Required => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
'Provider connection requires admin consent before use.',
|
||||
'ext.connection_needs_consent',
|
||||
$connection,
|
||||
),
|
||||
ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
'Provider connection consent failed. Retry admin consent before use.',
|
||||
'ext.connection_consent_failed',
|
||||
$connection,
|
||||
),
|
||||
ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
'Provider connection consent was revoked. Grant admin consent again before use.',
|
||||
'ext.connection_consent_revoked',
|
||||
$connection,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
if (! $consentStatus instanceof ProviderConsentStatus && is_string($consentStatus)) {
|
||||
$consentStatus = ProviderConsentStatus::tryFrom(trim($consentStatus));
|
||||
}
|
||||
|
||||
if ((string) $connection->status === 'needs_consent') {
|
||||
return ProviderConnectionResolution::blocked(
|
||||
return match ($consentStatus) {
|
||||
ProviderConsentStatus::Required => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
'Provider connection requires admin consent before use.',
|
||||
'ext.connection_needs_consent',
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
),
|
||||
ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
'Provider connection consent failed. Retry admin consent before use.',
|
||||
'ext.connection_consent_failed',
|
||||
$connection,
|
||||
),
|
||||
ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked(
|
||||
ProviderReasonCodes::ProviderConsentRevoked,
|
||||
'Provider connection consent was revoked. Grant admin consent again before use.',
|
||||
'ext.connection_consent_revoked',
|
||||
$connection,
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,55 +4,16 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Providers\Contracts\HealthResult;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
|
||||
final class ProviderConnectionStateProjector
|
||||
{
|
||||
/**
|
||||
* @return array{status: string, health_status: string}
|
||||
*/
|
||||
public function projectForConnection(ProviderConnection $connection): array
|
||||
{
|
||||
return $this->project(
|
||||
connectionType: $connection->connection_type,
|
||||
consentStatus: $connection->consent_status,
|
||||
verificationStatus: $connection->verification_status,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, health_status: string}
|
||||
*/
|
||||
public function project(
|
||||
ProviderConnectionType|string|null $connectionType,
|
||||
ProviderConsentStatus|string|null $consentStatus,
|
||||
ProviderVerificationStatus|string|null $verificationStatus,
|
||||
?string $currentStatus = null,
|
||||
): array {
|
||||
$resolvedConnectionType = $this->normalizeConnectionType($connectionType) ?? ProviderConnectionType::Platform;
|
||||
$resolvedConsentStatus = $this->normalizeConsentStatus($consentStatus) ?? ProviderConsentStatus::Unknown;
|
||||
$resolvedVerificationStatus = $this->normalizeVerificationStatus($verificationStatus) ?? ProviderVerificationStatus::Unknown;
|
||||
|
||||
$status = $currentStatus === 'disabled'
|
||||
? 'disabled'
|
||||
: $this->projectStatus($resolvedConnectionType, $resolvedConsentStatus, $resolvedVerificationStatus);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'health_status' => $this->projectHealthStatus($resolvedVerificationStatus),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* consent_status: ProviderConsentStatus,
|
||||
* verification_status: ProviderVerificationStatus,
|
||||
* status: string,
|
||||
* health_status: string,
|
||||
* last_error_reason_code: ?string,
|
||||
* last_error_message: ?string,
|
||||
* consent_error_code: ?string,
|
||||
@ -62,22 +23,14 @@ public function project(
|
||||
*/
|
||||
public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array
|
||||
{
|
||||
$currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status)
|
||||
?? (((string) $connection->status === 'needs_consent') ? ProviderConsentStatus::Required : ProviderConsentStatus::Unknown);
|
||||
$currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) ?? ProviderConsentStatus::Unknown;
|
||||
|
||||
$effectiveReasonCode = $result->healthy
|
||||
? null
|
||||
: $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode);
|
||||
|
||||
$consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy);
|
||||
$verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result->healthy, $result->healthStatus);
|
||||
|
||||
$projected = $this->project(
|
||||
connectionType: $connection->connection_type,
|
||||
consentStatus: $consentStatus,
|
||||
verificationStatus: $verificationStatus,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
$verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result);
|
||||
|
||||
$consentErrorCode = in_array($consentStatus, [
|
||||
ProviderConsentStatus::Required,
|
||||
@ -88,8 +41,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt
|
||||
return [
|
||||
'consent_status' => $consentStatus,
|
||||
'verification_status' => $verificationStatus,
|
||||
'status' => $projected['status'],
|
||||
'health_status' => $projected['health_status'],
|
||||
'last_error_reason_code' => $effectiveReasonCode,
|
||||
'last_error_message' => $result->healthy ? null : $result->message,
|
||||
'consent_error_code' => $consentErrorCode,
|
||||
@ -99,19 +50,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConnectionType(ProviderConnectionType|string|null $connectionType): ?ProviderConnectionType
|
||||
{
|
||||
if ($connectionType instanceof ProviderConnectionType) {
|
||||
return $connectionType;
|
||||
}
|
||||
|
||||
if (! is_string($connectionType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnectionType::tryFrom(trim($connectionType));
|
||||
}
|
||||
|
||||
private function normalizeConsentStatus(ProviderConsentStatus|string|null $consentStatus): ?ProviderConsentStatus
|
||||
{
|
||||
if ($consentStatus instanceof ProviderConsentStatus) {
|
||||
@ -139,41 +77,6 @@ private function normalizeVerificationStatus(
|
||||
return ProviderVerificationStatus::tryFrom(trim($verificationStatus));
|
||||
}
|
||||
|
||||
private function projectStatus(
|
||||
ProviderConnectionType $connectionType,
|
||||
ProviderConsentStatus $consentStatus,
|
||||
ProviderVerificationStatus $verificationStatus,
|
||||
): string {
|
||||
if ($connectionType === ProviderConnectionType::Dedicated && $verificationStatus === ProviderVerificationStatus::Blocked) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if ($consentStatus === ProviderConsentStatus::Failed) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if ($consentStatus !== ProviderConsentStatus::Granted) {
|
||||
return 'needs_consent';
|
||||
}
|
||||
|
||||
return match ($verificationStatus) {
|
||||
ProviderVerificationStatus::Blocked,
|
||||
ProviderVerificationStatus::Error => 'error',
|
||||
default => 'connected',
|
||||
};
|
||||
}
|
||||
|
||||
private function projectHealthStatus(ProviderVerificationStatus $verificationStatus): string
|
||||
{
|
||||
return match ($verificationStatus) {
|
||||
ProviderVerificationStatus::Healthy => 'ok',
|
||||
ProviderVerificationStatus::Degraded => 'degraded',
|
||||
ProviderVerificationStatus::Blocked,
|
||||
ProviderVerificationStatus::Error => 'down',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private function effectiveReasonCodeForVerification(
|
||||
ProviderConsentStatus $currentConsentStatus,
|
||||
?string $reasonCode,
|
||||
@ -211,17 +114,12 @@ private function consentStatusAfterVerification(
|
||||
|
||||
private function verificationStatusAfterVerification(
|
||||
?string $reasonCode,
|
||||
bool $healthy,
|
||||
string $healthStatus,
|
||||
HealthResult $result,
|
||||
): ProviderVerificationStatus {
|
||||
if ($healthy) {
|
||||
if ($result->healthy) {
|
||||
return ProviderVerificationStatus::Healthy;
|
||||
}
|
||||
|
||||
if ($healthStatus === 'degraded' || $reasonCode === ProviderReasonCodes::RateLimited) {
|
||||
return ProviderVerificationStatus::Degraded;
|
||||
}
|
||||
|
||||
if (in_array($reasonCode, [
|
||||
ProviderReasonCodes::ProviderConsentMissing,
|
||||
ProviderReasonCodes::ProviderConsentFailed,
|
||||
@ -238,6 +136,6 @@ private function verificationStatusAfterVerification(
|
||||
return ProviderVerificationStatus::Blocked;
|
||||
}
|
||||
|
||||
return ProviderVerificationStatus::Error;
|
||||
return $this->normalizeVerificationStatus($result->verificationStatus) ?? ProviderVerificationStatus::Error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderConnectionStateProjector;
|
||||
use App\Services\Providers\ProviderIdentityResolver;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
@ -27,7 +26,6 @@ public function __construct(
|
||||
private readonly ProviderOperationStartGate $providers,
|
||||
private readonly ProviderConnectionResolver $connections,
|
||||
private readonly ProviderIdentityResolver $identityResolver,
|
||||
private readonly ProviderConnectionStateProjector $stateProjector,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -126,17 +124,8 @@ public function providerConnectionCheckUsingConnection(
|
||||
);
|
||||
|
||||
if ($result->status === 'started') {
|
||||
$projectedState = $this->stateProjector->project(
|
||||
connectionType: $connection->connection_type,
|
||||
consentStatus: $connection->consent_status,
|
||||
verificationStatus: ProviderVerificationStatus::Pending,
|
||||
currentStatus: is_string($connection->status) ? $connection->status : null,
|
||||
);
|
||||
|
||||
$connection->update([
|
||||
'verification_status' => ProviderVerificationStatus::Pending,
|
||||
'status' => $projectedState['status'],
|
||||
'health_status' => $projectedState['health_status'],
|
||||
'last_error_reason_code' => null,
|
||||
'last_error_message' => null,
|
||||
]);
|
||||
|
||||
@ -95,6 +95,8 @@ enum AuditActionId: string
|
||||
case TenantReviewArchived = 'tenant_review.archived';
|
||||
case TenantReviewExported = 'tenant_review.exported';
|
||||
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).
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
@ -228,6 +230,8 @@ private static function labels(): array
|
||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
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.completed' => 'Baseline capture completed',
|
||||
'baseline.capture.failed' => 'Baseline capture failed',
|
||||
|
||||
@ -143,6 +143,9 @@ class Capabilities
|
||||
|
||||
public const TENANT_REVIEW_MANAGE = 'tenant_review.manage';
|
||||
|
||||
// Portfolio triage review progress
|
||||
public const TENANT_TRIAGE_REVIEW_MANAGE = 'tenant_triage_review.manage';
|
||||
|
||||
// Evidence snapshots
|
||||
public const EVIDENCE_VIEW = 'evidence.view';
|
||||
|
||||
|
||||
@ -49,8 +49,6 @@ final class BadgeCatalog
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
|
||||
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
|
||||
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
|
||||
@ -64,9 +62,13 @@ final class BadgeCatalog
|
||||
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
||||
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
|
||||
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
|
||||
BadgeDomain::TenantTriageReviewState->value => Domains\TenantTriageReviewStateBadge::class,
|
||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
||||
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
||||
BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class,
|
||||
BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class,
|
||||
BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -157,18 +159,6 @@ public static function normalizeState(mixed $value): ?string
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
public static function normalizeProviderConnectionStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'granted', 'connected' => 'connected',
|
||||
'consent_required', 'required', 'needs_admin_consent', 'needs_consent', 'unknown' => 'needs_consent',
|
||||
'failed', 'revoked', 'blocked' => 'error',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeProviderConsentStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
@ -195,17 +185,6 @@ public static function normalizeProviderVerificationStatus(mixed $value): ?strin
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeProviderConnectionHealth(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'healthy' => 'ok',
|
||||
'blocked', 'error' => 'down',
|
||||
default => $state,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string
|
||||
{
|
||||
$state = self::normalizeState($value);
|
||||
|
||||
@ -40,8 +40,6 @@ enum BadgeDomain: string
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
case ProviderConsentStatus = 'provider_connection.consent_status';
|
||||
case ProviderVerificationStatus = 'provider_connection.verification_status';
|
||||
case ProviderConnectionStatus = 'provider_connection.status';
|
||||
case ProviderConnectionHealth = 'provider_connection.health';
|
||||
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
||||
case VerificationCheckStatus = 'verification_check_status';
|
||||
case VerificationCheckSeverity = 'verification_check_severity';
|
||||
@ -55,7 +53,11 @@ enum BadgeDomain: string
|
||||
case EvidenceCompleteness = 'evidence_completeness';
|
||||
case TenantReviewStatus = 'tenant_review_status';
|
||||
case TenantReviewCompleteness = 'tenant_review_completeness';
|
||||
case TenantTriageReviewState = 'tenant_triage_review_state';
|
||||
case SystemHealth = 'system_health';
|
||||
case ReferenceResolutionState = 'reference_resolution_state';
|
||||
case DiffRowStatus = 'diff_row_status';
|
||||
case BaselineCompareMatrixState = 'baseline_compare_matrix_state';
|
||||
case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness';
|
||||
case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust';
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class BaselineCompareMatrixFreshnessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return match (BadgeCatalog::normalizeState($value)) {
|
||||
'fresh' => new BadgeSpec('Current result', 'success', 'heroicon-m-check-badge'),
|
||||
'stale' => new BadgeSpec('Refresh recommended', 'warning', 'heroicon-m-arrow-path'),
|
||||
'never_compared' => new BadgeSpec('Not compared yet', 'gray', 'heroicon-m-minus-circle'),
|
||||
'unknown' => new BadgeSpec('Freshness unknown', 'info', 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class BaselineCompareMatrixStateBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return match (BadgeCatalog::normalizeState($value)) {
|
||||
'match' => new BadgeSpec('Reference aligned', 'success', 'heroicon-m-check-circle'),
|
||||
'differ' => new BadgeSpec('Drift detected', 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
'missing' => new BadgeSpec('Missing from tenant', 'warning', 'heroicon-m-minus-circle'),
|
||||
'ambiguous' => new BadgeSpec('Identity ambiguous', 'info', 'heroicon-m-question-mark-circle'),
|
||||
'not_compared' => new BadgeSpec('Not compared', 'gray', 'heroicon-m-clock'),
|
||||
'stale_result' => new BadgeSpec('Result stale', 'warning', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class BaselineCompareMatrixTrustBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $value);
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class ProviderConnectionHealthBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeProviderConnectionHealth($value);
|
||||
|
||||
return match ($state) {
|
||||
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'down' => new BadgeSpec('Down', 'danger', 'heroicon-m-x-circle'),
|
||||
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class ProviderConnectionStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeProviderConnectionStatus($value);
|
||||
|
||||
return match ($state) {
|
||||
'connected' => new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'),
|
||||
'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||
'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class TenantTriageReviewStateBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
TenantTriageReview::DERIVED_STATE_NOT_REVIEWED => new BadgeSpec('Not reviewed', 'gray', 'heroicon-m-eye-slash'),
|
||||
TenantTriageReview::STATE_REVIEWED => new BadgeSpec('Reviewed', 'success', 'heroicon-m-check-circle'),
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => new BadgeSpec('Follow-up needed', 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => new BadgeSpec('Changed since review', 'warning', 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -245,6 +245,57 @@ public static function topReasons(array $byReason, int $limit = 5): array
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public static function subjectReasonsFromOperationRun(?OperationRun $run): array
|
||||
{
|
||||
$details = self::fromOperationRun($run);
|
||||
$buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : [];
|
||||
$reasonMap = [];
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
if (! is_array($bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reasonCode = self::stringOrNull($bucket['reason_code'] ?? null);
|
||||
|
||||
if ($reasonCode === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
||||
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
||||
|
||||
if ($policyType === null || $subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$compositeKey = self::subjectCompositeKey($policyType, $subjectKey);
|
||||
$reasonMap[$compositeKey] ??= [];
|
||||
$reasonMap[$compositeKey][] = $reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))),
|
||||
$reasonMap,
|
||||
);
|
||||
}
|
||||
|
||||
public static function subjectCompositeKey(string $policyType, string $subjectKey): string
|
||||
{
|
||||
return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $buckets
|
||||
* @return list<array<string, mixed>>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\CountDescriptor;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
@ -18,6 +19,36 @@ public function __construct(
|
||||
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
|
||||
{
|
||||
$reason = $stats->reasonCode !== null
|
||||
|
||||
@ -0,0 +1,884 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
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\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BaselineCompareMatrixBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly BaselineCompareExplanationRegistry $explanationRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(BaselineProfile $profile, User $user, array $filters = []): array
|
||||
{
|
||||
$normalizedFilters = $this->normalizeFilters($filters);
|
||||
|
||||
$assignments = BaselineTenantAssignment::query()
|
||||
->inWorkspace((int) $profile->workspace_id)
|
||||
->forBaselineProfile($profile)
|
||||
->with('tenant')
|
||||
->get();
|
||||
|
||||
$visibleTenants = $this->visibleTenants($assignments, $user);
|
||||
$referenceResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile);
|
||||
$referenceSnapshot = $this->resolvedSnapshot($referenceResolution);
|
||||
$referenceReasonCode = is_string($referenceResolution['reason_code'] ?? null)
|
||||
? trim((string) $referenceResolution['reason_code'])
|
||||
: null;
|
||||
|
||||
$reference = [
|
||||
'workspaceId' => (int) $profile->workspace_id,
|
||||
'baselineProfileId' => (int) $profile->getKey(),
|
||||
'baselineProfileName' => (string) $profile->name,
|
||||
'baselineStatus' => $profile->status instanceof BaselineProfileStatus
|
||||
? $profile->status->value
|
||||
: (string) $profile->status,
|
||||
'referenceSnapshotId' => $referenceSnapshot?->getKey(),
|
||||
'referenceSnapshotCapturedAt' => $referenceSnapshot?->captured_at?->toIso8601String(),
|
||||
'referenceState' => $referenceSnapshot instanceof BaselineSnapshot ? 'ready' : 'no_snapshot',
|
||||
'referenceReasonCode' => $referenceReasonCode,
|
||||
'assignedTenantCount' => $assignments->count(),
|
||||
'visibleTenantCount' => $visibleTenants->count(),
|
||||
];
|
||||
|
||||
$snapshotItems = $referenceSnapshot instanceof BaselineSnapshot
|
||||
? BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $referenceSnapshot->getKey())
|
||||
->orderBy('policy_type')
|
||||
->orderBy('subject_key')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
$policyTypeOptions = $snapshotItems
|
||||
->pluck('policy_type')
|
||||
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(static fn (string $type): array => [
|
||||
$type => InventoryPolicyTypeMeta::label($type) ?? $type,
|
||||
])
|
||||
->all();
|
||||
|
||||
$bundle = [
|
||||
'reference' => $reference,
|
||||
'filters' => [
|
||||
'policyTypes' => $normalizedFilters['policyTypes'],
|
||||
'states' => $normalizedFilters['states'],
|
||||
'severities' => $normalizedFilters['severities'],
|
||||
'tenantSort' => $normalizedFilters['tenantSort'],
|
||||
'subjectSort' => $normalizedFilters['subjectSort'],
|
||||
'focusedSubjectKey' => $normalizedFilters['focusedSubjectKey'],
|
||||
],
|
||||
'policyTypeOptions' => $policyTypeOptions,
|
||||
'stateOptions' => BadgeCatalog::options(BadgeDomain::BaselineCompareMatrixState, [
|
||||
'match',
|
||||
'differ',
|
||||
'missing',
|
||||
'ambiguous',
|
||||
'not_compared',
|
||||
'stale_result',
|
||||
]),
|
||||
'severityOptions' => BadgeCatalog::options(BadgeDomain::FindingSeverity, [
|
||||
Finding::SEVERITY_LOW,
|
||||
Finding::SEVERITY_MEDIUM,
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
]),
|
||||
'tenantSortOptions' => [
|
||||
'tenant_name' => 'Tenant name',
|
||||
'deviation_count' => 'Deviation count',
|
||||
'freshness_urgency' => 'Freshness urgency',
|
||||
],
|
||||
'subjectSortOptions' => [
|
||||
'deviation_breadth' => 'Deviation breadth',
|
||||
'policy_type' => 'Policy type',
|
||||
'display_name' => 'Display name',
|
||||
],
|
||||
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
|
||||
'match',
|
||||
'differ',
|
||||
'missing',
|
||||
'ambiguous',
|
||||
'not_compared',
|
||||
'stale_result',
|
||||
]),
|
||||
'freshnessLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixFreshness, [
|
||||
'fresh',
|
||||
'stale',
|
||||
'never_compared',
|
||||
'unknown',
|
||||
]),
|
||||
'trustLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixTrust, [
|
||||
'trustworthy',
|
||||
'limited_confidence',
|
||||
'diagnostic_only',
|
||||
'unusable',
|
||||
]),
|
||||
'tenantSummaries' => [],
|
||||
'subjectSummaries' => [],
|
||||
'rows' => [],
|
||||
'emptyState' => $this->emptyState(
|
||||
reference: $reference,
|
||||
snapshotItemsCount: $snapshotItems->count(),
|
||||
visibleTenantsCount: $visibleTenants->count(),
|
||||
),
|
||||
'hasActiveRuns' => false,
|
||||
];
|
||||
|
||||
if (! $referenceSnapshot instanceof BaselineSnapshot) {
|
||||
return $bundle;
|
||||
}
|
||||
|
||||
if ($visibleTenants->isEmpty()) {
|
||||
return $bundle;
|
||||
}
|
||||
|
||||
if ($snapshotItems->isEmpty()) {
|
||||
return $bundle;
|
||||
}
|
||||
|
||||
$tenantIds = $visibleTenants
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$latestRuns = OperationRun::latestBaselineCompareRunsForProfile(
|
||||
profile: $profile,
|
||||
tenantIds: $tenantIds,
|
||||
workspaceId: (int) $profile->workspace_id,
|
||||
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
||||
|
||||
$completedRuns = OperationRun::latestBaselineCompareRunsForProfile(
|
||||
profile: $profile,
|
||||
tenantIds: $tenantIds,
|
||||
workspaceId: (int) $profile->workspace_id,
|
||||
completedOnly: true,
|
||||
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
||||
|
||||
$findingMap = $this->findingMap($profile, $tenantIds, $completedRuns);
|
||||
$rows = [];
|
||||
|
||||
foreach ($snapshotItems as $item) {
|
||||
if (! $item instanceof BaselineSnapshotItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectKey = is_string($item->subject_key) ? trim($item->subject_key) : '';
|
||||
|
||||
if ($subjectKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subject = [
|
||||
'subjectKey' => $subjectKey,
|
||||
'policyType' => (string) $item->policy_type,
|
||||
'displayName' => $this->subjectDisplayName($item),
|
||||
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
|
||||
];
|
||||
|
||||
$cells = [];
|
||||
|
||||
foreach ($visibleTenants as $tenant) {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$latestRun = $latestRuns->get($tenantId);
|
||||
$completedRun = $completedRuns->get($tenantId);
|
||||
$cells[] = $this->cellFor(
|
||||
item: $item,
|
||||
tenant: $tenant,
|
||||
referenceSnapshot: $referenceSnapshot,
|
||||
latestRun: $latestRun instanceof OperationRun ? $latestRun : null,
|
||||
completedRun: $completedRun instanceof OperationRun ? $completedRun : null,
|
||||
finding: $findingMap[$this->cellKey($tenantId, $subjectKey)] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->rowMatchesFilters($subject, $cells, $normalizedFilters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'subject' => $this->subjectSummary($subject, $cells),
|
||||
'cells' => $cells,
|
||||
];
|
||||
}
|
||||
|
||||
$rows = $this->sortRows($rows, $normalizedFilters['subjectSort']);
|
||||
$tenantSummaries = $this->sortTenantSummaries(
|
||||
tenantSummaries: $this->tenantSummaries($visibleTenants, $latestRuns, $completedRuns, $rows, $referenceSnapshot),
|
||||
sort: $normalizedFilters['tenantSort'],
|
||||
);
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['cells'] = $this->sortCellsForTenants($row['cells'], $tenantSummaries);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$bundle['tenantSummaries'] = $tenantSummaries;
|
||||
$bundle['subjectSummaries'] = array_map(
|
||||
static fn (array $row): array => $row['subject'],
|
||||
$rows,
|
||||
);
|
||||
$bundle['rows'] = $rows;
|
||||
$bundle['emptyState'] = $this->emptyState(
|
||||
reference: $reference,
|
||||
snapshotItemsCount: $snapshotItems->count(),
|
||||
visibleTenantsCount: $visibleTenants->count(),
|
||||
renderedRowsCount: count($rows),
|
||||
);
|
||||
$bundle['hasActiveRuns'] = collect($tenantSummaries)
|
||||
->contains(static fn (array $summary): bool => in_array((string) ($summary['compareRunStatus'] ?? ''), [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
], true));
|
||||
|
||||
return $bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{
|
||||
* policyTypes: list<string>,
|
||||
* states: list<string>,
|
||||
* severities: list<string>,
|
||||
* tenantSort: string,
|
||||
* subjectSort: string,
|
||||
* focusedSubjectKey: ?string
|
||||
* }
|
||||
*/
|
||||
private function normalizeFilters(array $filters): array
|
||||
{
|
||||
$policyTypes = $this->normalizeStringList($filters['policy_type'] ?? $filters['policyTypes'] ?? []);
|
||||
$states = array_values(array_intersect(
|
||||
$this->normalizeStringList($filters['state'] ?? $filters['states'] ?? []),
|
||||
['match', 'differ', 'missing', 'ambiguous', 'not_compared', 'stale_result'],
|
||||
));
|
||||
$severities = array_values(array_intersect(
|
||||
$this->normalizeStringList($filters['severity'] ?? $filters['severities'] ?? []),
|
||||
[Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL],
|
||||
));
|
||||
$tenantSort = in_array((string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name'), [
|
||||
'tenant_name',
|
||||
'deviation_count',
|
||||
'freshness_urgency',
|
||||
], true)
|
||||
? (string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name')
|
||||
: 'tenant_name';
|
||||
$subjectSort = in_array((string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth'), [
|
||||
'deviation_breadth',
|
||||
'policy_type',
|
||||
'display_name',
|
||||
], true)
|
||||
? (string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth')
|
||||
: 'deviation_breadth';
|
||||
$focusedSubjectKey = $filters['subject_key'] ?? $filters['focusedSubjectKey'] ?? null;
|
||||
$focusedSubjectKey = is_string($focusedSubjectKey) && trim($focusedSubjectKey) !== ''
|
||||
? trim($focusedSubjectKey)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'policyTypes' => $policyTypes,
|
||||
'states' => $states,
|
||||
'severities' => $severities,
|
||||
'tenantSort' => $tenantSort,
|
||||
'subjectSort' => $subjectSort,
|
||||
'focusedSubjectKey' => $focusedSubjectKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeStringList(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))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BaselineTenantAssignment> $assignments
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
private function visibleTenants(Collection $assignments, User $user): Collection
|
||||
{
|
||||
return $assignments
|
||||
->map(static fn (BaselineTenantAssignment $assignment): ?Tenant => $assignment->tenant)
|
||||
->filter(fn (?Tenant $tenant): bool => $tenant instanceof Tenant
|
||||
&& $this->capabilityResolver->isMember($user, $tenant)
|
||||
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||
->sortBy(static fn (Tenant $tenant): string => Str::lower((string) $tenant->name))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resolution
|
||||
*/
|
||||
private function resolvedSnapshot(array $resolution): ?BaselineSnapshot
|
||||
{
|
||||
$snapshot = $resolution['effective_snapshot'] ?? $resolution['snapshot'] ?? null;
|
||||
|
||||
return $snapshot instanceof BaselineSnapshot ? $snapshot : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $tenantIds
|
||||
* @param Collection<int, OperationRun> $completedRuns
|
||||
* @return array<string, Finding>
|
||||
*/
|
||||
private function findingMap(BaselineProfile $profile, array $tenantIds, Collection $completedRuns): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->baselineCompareForProfile($profile)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->orderByDesc('last_seen_at')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tenantId = (int) $finding->tenant_id;
|
||||
$subjectKey = $this->subjectKeyForFinding($finding);
|
||||
|
||||
if ($subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$completedRun = $completedRuns->get($tenantId);
|
||||
|
||||
if (
|
||||
$completedRun instanceof OperationRun
|
||||
&& (int) ($finding->current_operation_run_id ?? 0) !== (int) $completedRun->getKey()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cellKey = $this->cellKey($tenantId, $subjectKey);
|
||||
|
||||
if (! array_key_exists($cellKey, $map)) {
|
||||
$map[$cellKey] = $finding;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function subjectKeyForFinding(Finding $finding): ?string
|
||||
{
|
||||
$subjectKey = data_get($finding->evidence_jsonb, 'subject_key');
|
||||
|
||||
if (is_string($subjectKey) && trim($subjectKey) !== '') {
|
||||
return trim($subjectKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function cellKey(int $tenantId, string $subjectKey): string
|
||||
{
|
||||
return $tenantId.'|'.trim(mb_strtolower($subjectKey));
|
||||
}
|
||||
|
||||
private function subjectDisplayName(BaselineSnapshotItem $item): ?string
|
||||
{
|
||||
$displayName = data_get($item->meta_jsonb, 'display_name');
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
return trim($displayName);
|
||||
}
|
||||
|
||||
return is_string($item->subject_key) && trim($item->subject_key) !== ''
|
||||
? Str::headline($item->subject_key)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function cellFor(
|
||||
BaselineSnapshotItem $item,
|
||||
Tenant $tenant,
|
||||
BaselineSnapshot $referenceSnapshot,
|
||||
?OperationRun $latestRun,
|
||||
?OperationRun $completedRun,
|
||||
?Finding $finding,
|
||||
): array {
|
||||
$subjectKey = (string) $item->subject_key;
|
||||
$policyType = (string) $item->policy_type;
|
||||
$completedAt = $completedRun?->finished_at;
|
||||
$policyTypeCovered = $this->policyTypeCovered($completedRun, $policyType);
|
||||
$subjectReasons = $completedRun instanceof OperationRun
|
||||
? (BaselineCompareEvidenceGapDetails::subjectReasonsFromOperationRun($completedRun)[BaselineCompareEvidenceGapDetails::subjectCompositeKey($policyType, $subjectKey)] ?? [])
|
||||
: [];
|
||||
$reasonCode = $subjectReasons[0] ?? $this->runReasonCode($completedRun);
|
||||
$changeType = is_string(data_get($finding?->evidence_jsonb, 'change_type')) ? (string) data_get($finding?->evidence_jsonb, 'change_type') : null;
|
||||
$staleResult = $this->isStaleResult($completedRun, $referenceSnapshot);
|
||||
$tenantTrustLevel = $this->tenantTrustLevel($completedRun);
|
||||
|
||||
$state = match (true) {
|
||||
! $completedRun instanceof OperationRun => 'not_compared',
|
||||
(string) $completedRun->outcome === OperationRunOutcome::Failed->value => 'not_compared',
|
||||
! $policyTypeCovered => 'not_compared',
|
||||
$staleResult => 'stale_result',
|
||||
$subjectReasons !== [] => 'ambiguous',
|
||||
$changeType === 'missing_policy' => 'missing',
|
||||
$finding instanceof Finding => 'differ',
|
||||
default => 'match',
|
||||
};
|
||||
|
||||
$trustLevel = match ($state) {
|
||||
'not_compared' => 'unusable',
|
||||
'stale_result' => 'limited_confidence',
|
||||
'ambiguous' => 'diagnostic_only',
|
||||
default => $tenantTrustLevel,
|
||||
};
|
||||
|
||||
return [
|
||||
'tenantId' => (int) $tenant->getKey(),
|
||||
'subjectKey' => $subjectKey,
|
||||
'state' => $state,
|
||||
'severity' => $finding instanceof Finding ? (string) $finding->severity : null,
|
||||
'trustLevel' => $trustLevel,
|
||||
'reasonCode' => $reasonCode,
|
||||
'compareRunId' => $completedRun?->getKey(),
|
||||
'findingId' => $finding?->getKey(),
|
||||
'findingWorkflowState' => $finding instanceof Finding ? (string) $finding->status : null,
|
||||
'lastComparedAt' => $completedAt?->toIso8601String(),
|
||||
'policyTypeCovered' => $policyTypeCovered,
|
||||
'latestRunId' => $latestRun?->getKey(),
|
||||
'latestRunStatus' => $latestRun?->status,
|
||||
];
|
||||
}
|
||||
|
||||
private function policyTypeCovered(?OperationRun $run, string $policyType): bool
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$coverage = data_get($run->context, 'baseline_compare.coverage');
|
||||
|
||||
if (! is_array($coverage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$coveredTypes = is_array($coverage['covered_types'] ?? null)
|
||||
? array_values(array_filter($coverage['covered_types'], 'is_string'))
|
||||
: [];
|
||||
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null)
|
||||
? array_values(array_filter($coverage['uncovered_types'], 'is_string'))
|
||||
: [];
|
||||
|
||||
if (in_array($policyType, $uncoveredTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($coveredTypes === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($policyType, $coveredTypes, true);
|
||||
}
|
||||
|
||||
private function runReasonCode(?OperationRun $run): ?string
|
||||
{
|
||||
$reasonCode = data_get($run?->context, 'baseline_compare.reason_code');
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||
? trim($reasonCode)
|
||||
: null;
|
||||
}
|
||||
|
||||
private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool
|
||||
{
|
||||
if (! $run instanceof OperationRun || ! $run->finished_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$runSnapshotId = data_get($run->context, 'baseline_snapshot_id');
|
||||
|
||||
if (is_numeric($runSnapshotId) && (int) $runSnapshotId !== (int) $referenceSnapshot->getKey()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($referenceSnapshot->captured_at && $run->finished_at->lt($referenceSnapshot->captured_at)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return BaselineCompareSummaryAssessor::isStaleComparedAt($run->finished_at);
|
||||
}
|
||||
|
||||
private function tenantTrustLevel(?OperationRun $run): string
|
||||
{
|
||||
return BadgeCatalog::normalizeState(
|
||||
$this->explanationRegistry->trustLevelForRun($run),
|
||||
) ?? 'unusable';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @param list<array<string, mixed>> $cells
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function rowMatchesFilters(array $subject, array $cells, array $filters): bool
|
||||
{
|
||||
if ($filters['policyTypes'] !== [] && ! in_array((string) $subject['policyType'], $filters['policyTypes'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters['focusedSubjectKey'] !== null && (string) $subject['subjectKey'] !== $filters['focusedSubjectKey']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cells as $cell) {
|
||||
if ($filters['states'] !== [] && ! in_array((string) ($cell['state'] ?? ''), $filters['states'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($filters['severities'] !== [] && ! in_array((string) ($cell['severity'] ?? ''), $filters['severities'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return $filters['states'] === [] && $filters['severities'] === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $cells
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function subjectSummary(array $subject, array $cells): array
|
||||
{
|
||||
return [
|
||||
'subjectKey' => $subject['subjectKey'],
|
||||
'policyType' => $subject['policyType'],
|
||||
'displayName' => $subject['displayName'],
|
||||
'baselineExternalId' => $subject['baselineExternalId'],
|
||||
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
|
||||
'missingBreadth' => $this->countStates($cells, ['missing']),
|
||||
'ambiguousBreadth' => $this->countStates($cells, ['ambiguous']),
|
||||
'notComparedBreadth' => $this->countStates($cells, ['not_compared']),
|
||||
'maxSeverity' => $this->maxSeverity($cells),
|
||||
'trustLevel' => $this->worstTrustLevel($cells),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Tenant> $visibleTenants
|
||||
* @param Collection<int, OperationRun> $latestRuns
|
||||
* @param Collection<int, OperationRun> $completedRuns
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function tenantSummaries(
|
||||
Collection $visibleTenants,
|
||||
Collection $latestRuns,
|
||||
Collection $completedRuns,
|
||||
array $rows,
|
||||
BaselineSnapshot $referenceSnapshot,
|
||||
): array {
|
||||
$summaries = [];
|
||||
|
||||
foreach ($visibleTenants as $tenant) {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$latestRun = $latestRuns->get($tenantId);
|
||||
$completedRun = $completedRuns->get($tenantId);
|
||||
$cells = array_map(
|
||||
static fn (array $row): array => collect($row['cells'])->firstWhere('tenantId', $tenantId) ?? [],
|
||||
$rows,
|
||||
);
|
||||
|
||||
$summaries[] = [
|
||||
'tenantId' => $tenantId,
|
||||
'tenantName' => (string) $tenant->name,
|
||||
'compareRunId' => $latestRun?->getKey(),
|
||||
'compareRunStatus' => $latestRun?->status,
|
||||
'compareRunOutcome' => $latestRun?->outcome,
|
||||
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
|
||||
'lastComparedAt' => $completedRun?->finished_at?->toIso8601String(),
|
||||
'matchedCount' => $this->countStates($cells, ['match']),
|
||||
'differingCount' => $this->countStates($cells, ['differ']),
|
||||
'missingCount' => $this->countStates($cells, ['missing']),
|
||||
'ambiguousCount' => $this->countStates($cells, ['ambiguous']),
|
||||
'notComparedCount' => $this->countStates($cells, ['not_compared']),
|
||||
'maxSeverity' => $this->maxSeverity($cells),
|
||||
'trustLevel' => $this->worstTrustLevel($cells),
|
||||
];
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
private function freshnessState(?OperationRun $completedRun, BaselineSnapshot $referenceSnapshot): string
|
||||
{
|
||||
if (! $completedRun instanceof OperationRun) {
|
||||
return 'never_compared';
|
||||
}
|
||||
|
||||
if ((string) $completedRun->outcome === OperationRunOutcome::Failed->value) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if ($this->isStaleResult($completedRun, $referenceSnapshot)) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $cells
|
||||
* @param array<int, string> $states
|
||||
*/
|
||||
private function countStates(array $cells, array $states): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$cells,
|
||||
static fn (array $cell): bool => in_array((string) ($cell['state'] ?? ''), $states, true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $cells
|
||||
*/
|
||||
private function maxSeverity(array $cells): ?string
|
||||
{
|
||||
$ranked = collect($cells)
|
||||
->map(static fn (array $cell): ?string => is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null)
|
||||
->filter()
|
||||
->sortByDesc(fn (string $severity): int => $this->severityRank($severity))
|
||||
->values();
|
||||
|
||||
return $ranked->first();
|
||||
}
|
||||
|
||||
private function severityRank(string $severity): int
|
||||
{
|
||||
return match ($severity) {
|
||||
Finding::SEVERITY_CRITICAL => 4,
|
||||
Finding::SEVERITY_HIGH => 3,
|
||||
Finding::SEVERITY_MEDIUM => 2,
|
||||
Finding::SEVERITY_LOW => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $cells
|
||||
*/
|
||||
private function worstTrustLevel(array $cells): string
|
||||
{
|
||||
return collect($cells)
|
||||
->map(static fn (array $cell): string => (string) ($cell['trustLevel'] ?? 'unusable'))
|
||||
->sortByDesc(fn (string $trust): int => $this->trustRank($trust))
|
||||
->first() ?? 'unusable';
|
||||
}
|
||||
|
||||
private function trustRank(string $trustLevel): int
|
||||
{
|
||||
return match ($trustLevel) {
|
||||
'unusable' => 4,
|
||||
'diagnostic_only' => 3,
|
||||
'limited_confidence' => 2,
|
||||
'trustworthy' => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortRows(array $rows, string $sort): array
|
||||
{
|
||||
usort($rows, function (array $left, array $right) use ($sort): int {
|
||||
$leftSubject = $left['subject'] ?? [];
|
||||
$rightSubject = $right['subject'] ?? [];
|
||||
|
||||
return match ($sort) {
|
||||
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))]
|
||||
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))],
|
||||
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
|
||||
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
|
||||
default => [
|
||||
-1 * (int) ($leftSubject['deviationBreadth'] ?? 0),
|
||||
-1 * (int) ($leftSubject['ambiguousBreadth'] ?? 0),
|
||||
Str::lower((string) ($leftSubject['displayName'] ?? '')),
|
||||
] <=> [
|
||||
-1 * (int) ($rightSubject['deviationBreadth'] ?? 0),
|
||||
-1 * (int) ($rightSubject['ambiguousBreadth'] ?? 0),
|
||||
Str::lower((string) ($rightSubject['displayName'] ?? '')),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return array_values($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tenantSummaries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortTenantSummaries(array $tenantSummaries, string $sort): array
|
||||
{
|
||||
usort($tenantSummaries, function (array $left, array $right) use ($sort): int {
|
||||
return match ($sort) {
|
||||
'deviation_count' => [
|
||||
-1 * ((int) ($left['differingCount'] ?? 0) + (int) ($left['missingCount'] ?? 0) + (int) ($left['ambiguousCount'] ?? 0)),
|
||||
Str::lower((string) ($left['tenantName'] ?? '')),
|
||||
] <=> [
|
||||
-1 * ((int) ($right['differingCount'] ?? 0) + (int) ($right['missingCount'] ?? 0) + (int) ($right['ambiguousCount'] ?? 0)),
|
||||
Str::lower((string) ($right['tenantName'] ?? '')),
|
||||
],
|
||||
'freshness_urgency' => [
|
||||
-1 * $this->freshnessRank((string) ($left['freshnessState'] ?? 'unknown')),
|
||||
Str::lower((string) ($left['tenantName'] ?? '')),
|
||||
] <=> [
|
||||
-1 * $this->freshnessRank((string) ($right['freshnessState'] ?? 'unknown')),
|
||||
Str::lower((string) ($right['tenantName'] ?? '')),
|
||||
],
|
||||
default => Str::lower((string) ($left['tenantName'] ?? '')) <=> Str::lower((string) ($right['tenantName'] ?? '')),
|
||||
};
|
||||
});
|
||||
|
||||
return array_values($tenantSummaries);
|
||||
}
|
||||
|
||||
private function freshnessRank(string $freshnessState): int
|
||||
{
|
||||
return match ($freshnessState) {
|
||||
'stale' => 4,
|
||||
'unknown' => 3,
|
||||
'never_compared' => 2,
|
||||
'fresh' => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $cells
|
||||
* @param list<array<string, mixed>> $tenantSummaries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortCellsForTenants(array $cells, array $tenantSummaries): array
|
||||
{
|
||||
$order = collect($tenantSummaries)
|
||||
->values()
|
||||
->mapWithKeys(static fn (array $summary, int $index): array => [
|
||||
(int) ($summary['tenantId'] ?? 0) => $index,
|
||||
]);
|
||||
|
||||
usort($cells, static fn (array $left, array $right): int => ($order[(int) ($left['tenantId'] ?? 0)] ?? 9999) <=> ($order[(int) ($right['tenantId'] ?? 0)] ?? 9999));
|
||||
|
||||
return array_values($cells);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $reference
|
||||
* @return array{title: string, body: string}|null
|
||||
*/
|
||||
private function emptyState(
|
||||
array $reference,
|
||||
int $snapshotItemsCount,
|
||||
int $visibleTenantsCount,
|
||||
int $renderedRowsCount = 0,
|
||||
): ?array {
|
||||
if (($reference['referenceState'] ?? null) !== 'ready') {
|
||||
return [
|
||||
'title' => 'No usable reference snapshot',
|
||||
'body' => 'Capture a complete baseline snapshot before using the compare matrix.',
|
||||
];
|
||||
}
|
||||
|
||||
if ((int) ($reference['assignedTenantCount'] ?? 0) === 0) {
|
||||
return [
|
||||
'title' => 'No assigned tenants',
|
||||
'body' => 'Assign tenants to this baseline profile to build the visible compare set.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($visibleTenantsCount === 0) {
|
||||
return [
|
||||
'title' => 'No visible assigned tenants',
|
||||
'body' => 'This baseline has assigned tenants, but none are visible in your current tenant scope.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($snapshotItemsCount === 0) {
|
||||
return [
|
||||
'title' => 'No baseline subjects',
|
||||
'body' => 'The active reference snapshot completed without any baseline subjects to compare.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($renderedRowsCount === 0) {
|
||||
return [
|
||||
'title' => 'No rows match the current filters',
|
||||
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $values
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function legendSpecs(BadgeDomain $domain, array $values): array
|
||||
{
|
||||
return array_map(
|
||||
static function (string $value) use ($domain): array {
|
||||
$spec = BadgeCatalog::spec($domain, $value);
|
||||
|
||||
return [
|
||||
'value' => $value,
|
||||
'label' => $spec->label,
|
||||
'color' => $spec->color,
|
||||
'icon' => $spec->icon,
|
||||
'iconColor' => $spec->iconColor,
|
||||
];
|
||||
},
|
||||
$values,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,28 @@ final class BaselineCompareSummaryAssessor
|
||||
{
|
||||
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
|
||||
{
|
||||
$explanation = $stats->operatorExplanation();
|
||||
@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
|
||||
return self::isStaleComparedAt($stats->lastComparedIso);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
|
||||
namespace App\Support\Navigation;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final readonly class CanonicalNavigationContext
|
||||
@ -63,4 +66,31 @@ public function toQuery(): array
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
if ($state === null || ! $this->matchesScope($tenant, $request, $state)) {
|
||||
if ($state === null || ! $this->matchesRequestScope($tenant, $request, $state)) {
|
||||
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
|
||||
|
||||
return null;
|
||||
@ -61,6 +61,26 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
|
||||
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{
|
||||
* sourceSurface: string,
|
||||
@ -72,7 +92,30 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
|
||||
* returnFilters: array<string, mixed>|null
|
||||
* } $state
|
||||
*/
|
||||
private function matchesScope(Tenant $tenant, Request $request, array $state): bool
|
||||
private function matchesRequestScope(Tenant $tenant, Request $request, array $state): bool
|
||||
{
|
||||
if (! $this->matchesTenantScope($tenant, $state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = $state['workspaceId'];
|
||||
|
||||
return $workspaceId === null
|
||||
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* sourceSurface: string,
|
||||
* tenantRouteKey: string|null,
|
||||
* workspaceId: int|null,
|
||||
* concernFamily: string,
|
||||
* concernState: string,
|
||||
* concernReason: string|null,
|
||||
* returnFilters: array<string, mixed>|null
|
||||
* } $state
|
||||
*/
|
||||
private function matchesTenantScope(Tenant $tenant, array $state): bool
|
||||
{
|
||||
$tenantRouteKey = $state['tenantRouteKey'];
|
||||
|
||||
@ -92,7 +135,7 @@ private function matchesScope(Tenant $tenant, Request $request, array $state): b
|
||||
$workspaceId = $state['workspaceId'];
|
||||
|
||||
return $workspaceId === null
|
||||
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
|
||||
|| (int) $tenant->workspace_id === $workspaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -55,6 +55,7 @@ final class PortfolioArrivalContextToken
|
||||
private const array RETURN_FILTER_ALLOWLIST = [
|
||||
'backup_posture' => true,
|
||||
'recovery_evidence' => true,
|
||||
'review_state' => true,
|
||||
'triage_sort' => true,
|
||||
];
|
||||
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\PortfolioTriage;
|
||||
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use JsonException;
|
||||
|
||||
final class TenantTriageReviewFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
* @return array{
|
||||
* concern_family: string,
|
||||
* concern_state: string,
|
||||
* fingerprint: string,
|
||||
* snapshot: array{
|
||||
* concernFamily: string,
|
||||
* concernState: string,
|
||||
* reasonCode: ?string,
|
||||
* severityKey: ?string,
|
||||
* supportingKey: ?string
|
||||
* }
|
||||
* }|null
|
||||
*/
|
||||
public function forConcernFamily(
|
||||
string $concernFamily,
|
||||
?TenantBackupHealthAssessment $backupHealth,
|
||||
?array $recoveryEvidence,
|
||||
): ?array {
|
||||
return match ($concernFamily) {
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->forBackupHealth($backupHealth),
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->forRecoveryEvidence($recoveryEvidence),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* concern_family: string,
|
||||
* concern_state: string,
|
||||
* fingerprint: string,
|
||||
* snapshot: array{
|
||||
* concernFamily: string,
|
||||
* concernState: string,
|
||||
* reasonCode: ?string,
|
||||
* severityKey: ?string,
|
||||
* supportingKey: ?string
|
||||
* }
|
||||
* }|null
|
||||
*/
|
||||
public function forBackupHealth(?TenantBackupHealthAssessment $assessment): ?array
|
||||
{
|
||||
if (! $assessment instanceof TenantBackupHealthAssessment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array($assessment->posture, [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = [
|
||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'concernState' => $assessment->posture,
|
||||
'reasonCode' => $assessment->primaryReason,
|
||||
'severityKey' => $assessment->primaryReason,
|
||||
'supportingKey' => $assessment->primaryReason,
|
||||
];
|
||||
|
||||
return [
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'concern_state' => $assessment->posture,
|
||||
'fingerprint' => $this->hash($snapshot),
|
||||
'snapshot' => $snapshot,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $recoveryEvidence
|
||||
* @return array{
|
||||
* concern_family: string,
|
||||
* concern_state: string,
|
||||
* fingerprint: string,
|
||||
* snapshot: array{
|
||||
* concernFamily: string,
|
||||
* concernState: string,
|
||||
* reasonCode: ?string,
|
||||
* severityKey: ?string,
|
||||
* supportingKey: ?string
|
||||
* }
|
||||
* }|null
|
||||
*/
|
||||
public function forRecoveryEvidence(?array $recoveryEvidence): ?array
|
||||
{
|
||||
$state = is_array($recoveryEvidence)
|
||||
? TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence)
|
||||
: null;
|
||||
|
||||
if (! in_array($state, [
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = is_string($recoveryEvidence['reason'] ?? null)
|
||||
? $recoveryEvidence['reason']
|
||||
: null;
|
||||
$supportingKey = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: $reason;
|
||||
|
||||
$snapshot = [
|
||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
'concernState' => $state,
|
||||
'reasonCode' => $reason,
|
||||
'severityKey' => $reason,
|
||||
'supportingKey' => $supportingKey,
|
||||
];
|
||||
|
||||
return [
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
'concern_state' => $state,
|
||||
'fingerprint' => $this->hash($snapshot),
|
||||
'snapshot' => $snapshot,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* concernFamily: string,
|
||||
* concernState: string,
|
||||
* reasonCode: ?string,
|
||||
* severityKey: ?string,
|
||||
* supportingKey: ?string
|
||||
* } $snapshot
|
||||
*/
|
||||
private function hash(array $snapshot): string
|
||||
{
|
||||
try {
|
||||
return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
|
||||
} catch (JsonException) {
|
||||
return hash('sha256', implode(':', array_map(
|
||||
static fn (mixed $value): string => is_scalar($value) || $value === null
|
||||
? (string) $value
|
||||
: '',
|
||||
$snapshot,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\PortfolioTriage;
|
||||
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
|
||||
final readonly class TenantTriageReviewStateResolver
|
||||
{
|
||||
public function __construct(
|
||||
private TenantTriageReviewFingerprint $fingerprints,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<int> $tenantIds
|
||||
* @param array<int, TenantBackupHealthAssessment> $backupHealthByTenant
|
||||
* @param array<int, array<string, mixed>> $recoveryEvidenceByTenant
|
||||
* @return array{
|
||||
* rows: array<int, array{
|
||||
* backup_health: array<string, mixed>,
|
||||
* recovery_evidence: array<string, mixed>
|
||||
* }>,
|
||||
* summaries: array<string, array{
|
||||
* concern_family: string,
|
||||
* affected_total: int,
|
||||
* reviewed_count: int,
|
||||
* follow_up_needed_count: int,
|
||||
* changed_since_review_count: int,
|
||||
* not_reviewed_count: int
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public function resolveMany(
|
||||
int $workspaceId,
|
||||
array $tenantIds,
|
||||
array $backupHealthByTenant = [],
|
||||
array $recoveryEvidenceByTenant = [],
|
||||
): array {
|
||||
$tenantIds = array_values(array_unique(array_map(static fn (int|string $tenantId): int => (int) $tenantId, $tenantIds)));
|
||||
|
||||
$emptySummary = [
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH),
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE),
|
||||
];
|
||||
|
||||
if ($workspaceId <= 0 || $tenantIds === []) {
|
||||
return [
|
||||
'rows' => [],
|
||||
'summaries' => $emptySummary,
|
||||
];
|
||||
}
|
||||
|
||||
$activeReviews = TenantTriageReview::query()
|
||||
->with('reviewer:id,name,email')
|
||||
->forWorkspace($workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->active()
|
||||
->orderByDesc('reviewed_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->groupBy([
|
||||
'tenant_id',
|
||||
'concern_family',
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
$summaries = $emptySummary;
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$rows[$tenantId] = [
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->resolveFamily(
|
||||
tenantId: $tenantId,
|
||||
concernFamily: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
currentConcern: $this->fingerprints->forBackupHealth($backupHealthByTenant[$tenantId] ?? null),
|
||||
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH][0] ?? null,
|
||||
),
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->resolveFamily(
|
||||
tenantId: $tenantId,
|
||||
concernFamily: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
currentConcern: $this->fingerprints->forRecoveryEvidence($recoveryEvidenceByTenant[$tenantId] ?? null),
|
||||
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE][0] ?? null,
|
||||
),
|
||||
];
|
||||
|
||||
foreach ($rows[$tenantId] as $family => $row) {
|
||||
if (($row['current_concern_present'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summaries[$family]['affected_total']++;
|
||||
|
||||
match ($row['derived_state']) {
|
||||
TenantTriageReview::STATE_REVIEWED => $summaries[$family]['reviewed_count']++,
|
||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $summaries[$family]['follow_up_needed_count']++,
|
||||
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => $summaries[$family]['changed_since_review_count']++,
|
||||
default => $summaries[$family]['not_reviewed_count']++,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'summaries' => $summaries,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* concern_family: string,
|
||||
* concern_state: string,
|
||||
* fingerprint: string,
|
||||
* snapshot: array<string, mixed>
|
||||
* }|null $currentConcern
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveFamily(
|
||||
int $tenantId,
|
||||
string $concernFamily,
|
||||
?array $currentConcern,
|
||||
?TenantTriageReview $activeReview,
|
||||
): array {
|
||||
$reviewerName = null;
|
||||
|
||||
if ($activeReview?->reviewer !== null) {
|
||||
$reviewerName = filled($activeReview->reviewer->name)
|
||||
? (string) $activeReview->reviewer->name
|
||||
: (filled($activeReview->reviewer->email) ? (string) $activeReview->reviewer->email : null);
|
||||
}
|
||||
|
||||
if ($currentConcern === null) {
|
||||
return [
|
||||
'tenant_id' => $tenantId,
|
||||
'concern_family' => $concernFamily,
|
||||
'current_concern_present' => false,
|
||||
'current_state' => null,
|
||||
'current_fingerprint' => null,
|
||||
'review_fingerprint' => $activeReview?->review_fingerprint,
|
||||
'derived_state' => null,
|
||||
'reviewed_at' => $activeReview?->reviewed_at,
|
||||
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
|
||||
'reviewed_by_user_name' => $reviewerName,
|
||||
'current_snapshot' => null,
|
||||
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : null,
|
||||
];
|
||||
}
|
||||
|
||||
$derivedState = match (true) {
|
||||
! $activeReview instanceof TenantTriageReview => TenantTriageReview::DERIVED_STATE_NOT_REVIEWED,
|
||||
$activeReview->review_fingerprint !== $currentConcern['fingerprint'] => TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
||||
default => (string) $activeReview->current_state,
|
||||
};
|
||||
|
||||
return [
|
||||
'tenant_id' => $tenantId,
|
||||
'concern_family' => $concernFamily,
|
||||
'current_concern_present' => true,
|
||||
'current_state' => $currentConcern['concern_state'],
|
||||
'current_fingerprint' => $currentConcern['fingerprint'],
|
||||
'review_fingerprint' => $activeReview?->review_fingerprint,
|
||||
'derived_state' => $derivedState,
|
||||
'reviewed_at' => $activeReview?->reviewed_at,
|
||||
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
|
||||
'reviewed_by_user_name' => $reviewerName,
|
||||
'current_snapshot' => $currentConcern['snapshot'],
|
||||
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : $currentConcern['snapshot'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* concern_family: string,
|
||||
* affected_total: int,
|
||||
* reviewed_count: int,
|
||||
* follow_up_needed_count: int,
|
||||
* changed_since_review_count: int,
|
||||
* not_reviewed_count: int
|
||||
* }
|
||||
*/
|
||||
private function emptySummary(string $concernFamily): array
|
||||
{
|
||||
return [
|
||||
'concern_family' => $concernFamily,
|
||||
'affected_total' => 0,
|
||||
'reviewed_count' => 0,
|
||||
'follow_up_needed_count' => 0,
|
||||
'changed_since_review_count' => 0,
|
||||
'not_reviewed_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -660,6 +660,22 @@ private function resolveTenantWithRecord(?Model $record = null): ?Tenant
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->action instanceof Action) {
|
||||
$actionRecord = $this->action->getRecord(withDefault: false);
|
||||
|
||||
if ($actionRecord instanceof Tenant) {
|
||||
return $actionRecord;
|
||||
}
|
||||
|
||||
if ($actionRecord instanceof Model && method_exists($actionRecord, 'relationLoaded') && $actionRecord->relationLoaded('tenant')) {
|
||||
$relatedTenant = $actionRecord->getRelation('tenant');
|
||||
|
||||
if ($relatedTenant instanceof Tenant) {
|
||||
return $relatedTenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a record is set from forTableAction, try to resolve it
|
||||
if ($this->record !== null) {
|
||||
$resolved = $this->record instanceof Closure
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use ReflectionObject;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@ -39,6 +40,10 @@ final class WorkspaceUiEnforcement
|
||||
|
||||
private Model|Closure|null $record = null;
|
||||
|
||||
private bool $preserveExistingVisibility = false;
|
||||
|
||||
private bool $preserveExistingDisabled = false;
|
||||
|
||||
private function __construct(Action $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
@ -58,6 +63,14 @@ public static function forTableAction(Action $action, Model|Closure $record): se
|
||||
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
|
||||
{
|
||||
$this->requireMembership = $require;
|
||||
@ -95,6 +108,20 @@ public function tooltip(string $message): self
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function preserveVisibility(): self
|
||||
{
|
||||
$this->preserveExistingVisibility = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function preserveDisabled(): self
|
||||
{
|
||||
$this->preserveExistingDisabled = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function apply(): Action
|
||||
{
|
||||
$this->applyVisibility();
|
||||
@ -111,10 +138,22 @@ private function applyVisibility(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$this->action->visible(function (?Model $record = null): bool {
|
||||
$existingVisibility = $this->preserveExistingVisibility
|
||||
? $this->getExistingVisibilityCondition()
|
||||
: null;
|
||||
|
||||
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
return $context->isMember;
|
||||
if (! $context->isMember) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($existingVisibility === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
||||
});
|
||||
}
|
||||
|
||||
@ -126,7 +165,15 @@ private function applyDisabledState(): void
|
||||
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
|
||||
$this->action->disabled(function (?Model $record = null): bool {
|
||||
$existingDisabled = $this->preserveExistingDisabled
|
||||
? $this->getExistingDisabledCondition()
|
||||
: null;
|
||||
|
||||
$this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool {
|
||||
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if (! $context->isMember) {
|
||||
@ -173,6 +220,96 @@ private function applyServerSideGuard(): void
|
||||
});
|
||||
}
|
||||
|
||||
private function getExistingVisibilityCondition(): bool|Closure|null
|
||||
{
|
||||
try {
|
||||
$ref = new ReflectionObject($this->action);
|
||||
|
||||
if (! $ref->hasProperty('isVisible')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$property = $ref->getProperty('isVisible');
|
||||
$property->setAccessible(true);
|
||||
|
||||
/** @var bool|Closure $value */
|
||||
$value = $property->getValue($this->action);
|
||||
|
||||
return $value;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
||||
{
|
||||
if (is_bool($condition)) {
|
||||
return $condition;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionFunction($condition);
|
||||
$parameters = $reflection->getParameters();
|
||||
|
||||
if ($parameters === []) {
|
||||
return (bool) $condition();
|
||||
}
|
||||
|
||||
if ($record === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $condition($record);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getExistingDisabledCondition(): bool|Closure|null
|
||||
{
|
||||
try {
|
||||
$ref = new ReflectionObject($this->action);
|
||||
|
||||
if (! $ref->hasProperty('isDisabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$property = $ref->getProperty('isDisabled');
|
||||
$property->setAccessible(true);
|
||||
|
||||
/** @var bool|Closure $value */
|
||||
$value = $property->getValue($this->action);
|
||||
|
||||
return $value;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
|
||||
{
|
||||
if (is_bool($condition)) {
|
||||
return $condition;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionFunction($condition);
|
||||
$parameters = $reflection->getParameters();
|
||||
|
||||
if ($parameters === []) {
|
||||
return (bool) $condition();
|
||||
}
|
||||
|
||||
if ($record === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $condition($record);
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -30,11 +30,11 @@ public static function tenantsIndex(): string
|
||||
return Tenants::getUrl(panel: 'system');
|
||||
}
|
||||
|
||||
public static function tenantDetail(Tenant|int $tenant): string
|
||||
public static function tenantDetail(Tenant|string|int $tenant): string
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant;
|
||||
$tenantRouteKey = self::tenantRouteKey($tenant);
|
||||
|
||||
return ViewTenant::getUrl(['tenant' => $tenantId], panel: 'system');
|
||||
return ViewTenant::getUrl(['tenant' => $tenantRouteKey], panel: 'system');
|
||||
}
|
||||
|
||||
public static function adminWorkspace(Workspace|int $workspace): string
|
||||
@ -44,10 +44,19 @@ public static function adminWorkspace(Workspace|int $workspace): string
|
||||
return route('filament.admin.resources.workspaces.view', ['record' => $workspaceId]);
|
||||
}
|
||||
|
||||
public static function adminTenant(Tenant|int $tenant): string
|
||||
public static function adminTenant(Tenant|string|int $tenant): string
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant;
|
||||
$tenantRouteKey = self::tenantRouteKey($tenant);
|
||||
|
||||
return route('filament.admin.resources.tenants.view', ['record' => $tenantId]);
|
||||
return route('filament.admin.resources.tenants.view', ['record' => $tenantRouteKey]);
|
||||
}
|
||||
|
||||
private static function tenantRouteKey(Tenant|string|int $tenant): string
|
||||
{
|
||||
if ($tenant instanceof Tenant) {
|
||||
return (string) $tenant->getRouteKey();
|
||||
}
|
||||
|
||||
return (string) $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,10 +6,12 @@
|
||||
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||
use ReflectionClass;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use Throwable;
|
||||
|
||||
final class ActionSurfaceDiscovery
|
||||
{
|
||||
@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
|
||||
{
|
||||
$scopes = [ActionSurfacePanelScope::Tenant];
|
||||
|
||||
if (in_array($className, $adminScopedClasses, true)) {
|
||||
if (
|
||||
in_array($className, $adminScopedClasses, true)
|
||||
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
|
||||
) {
|
||||
$scopes[] = ActionSurfacePanelScope::Admin;
|
||||
}
|
||||
|
||||
@ -228,6 +233,37 @@ private function isDeclaredSystemTablePage(string $className): bool
|
||||
&& 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>
|
||||
*/
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -31,6 +32,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
@ -47,6 +49,7 @@ public function __construct(
|
||||
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
||||
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
||||
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -66,6 +69,7 @@ public function build(Workspace $workspace, User $user): array
|
||||
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
$navigationContext = $this->workspaceOverviewNavigationContext();
|
||||
$tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts);
|
||||
$triageReviewProgress = $this->triageReviewProgress($workspaceId, $tenantContexts);
|
||||
$attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext);
|
||||
|
||||
$governanceAttentionTenantCount = count(array_filter(
|
||||
@ -141,6 +145,7 @@ public function build(Workspace $workspace, User $user): array
|
||||
totalAlertFailuresCount: $totalAlertFailuresCount,
|
||||
canViewAlerts: $canViewAlerts,
|
||||
tenantContexts: $tenantContexts,
|
||||
triageReviewSummaries: $triageReviewProgress['summaries'],
|
||||
user: $user,
|
||||
navigationContext: $navigationContext,
|
||||
);
|
||||
@ -164,6 +169,7 @@ public function build(Workspace $workspace, User $user): array
|
||||
'workspace_name' => (string) $workspace->name,
|
||||
'accessible_tenant_count' => $accessibleTenants->count(),
|
||||
'summary_metrics' => $summaryMetrics,
|
||||
'triage_review_progress' => $triageReviewProgress['families'],
|
||||
'attention_items' => $attentionItems,
|
||||
'attention_empty_state' => $attentionEmptyState,
|
||||
'recent_operations' => $recentOperations,
|
||||
@ -828,6 +834,7 @@ private function summaryMetrics(
|
||||
int $totalAlertFailuresCount,
|
||||
bool $canViewAlerts,
|
||||
array $tenantContexts,
|
||||
array $triageReviewSummaries,
|
||||
User $user,
|
||||
CanonicalNavigationContext $navigationContext,
|
||||
): array {
|
||||
@ -861,7 +868,11 @@ private function summaryMetrics(
|
||||
label: 'Backup attention',
|
||||
value: $backupAttentionTenantCount,
|
||||
category: 'backup_health',
|
||||
description: 'Visible tenants with non-healthy backup posture.',
|
||||
description: $this->reviewSummaryMetricDescription(
|
||||
family: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
baseDescription: 'Visible tenants with non-healthy backup posture.',
|
||||
triageReviewSummaries: $triageReviewSummaries,
|
||||
),
|
||||
color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray',
|
||||
destination: $this->attentionMetricDestination(
|
||||
tenantContexts: $tenantContexts,
|
||||
@ -874,7 +885,11 @@ private function summaryMetrics(
|
||||
label: 'Recovery attention',
|
||||
value: $recoveryAttentionTenantCount,
|
||||
category: 'recovery_evidence',
|
||||
description: 'Visible tenants with weakened or unvalidated recovery evidence.',
|
||||
description: $this->reviewSummaryMetricDescription(
|
||||
family: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
baseDescription: 'Visible tenants with weakened or unvalidated recovery evidence.',
|
||||
triageReviewSummaries: $triageReviewSummaries,
|
||||
),
|
||||
color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray',
|
||||
destination: $this->attentionMetricDestination(
|
||||
tenantContexts: $tenantContexts,
|
||||
@ -912,6 +927,83 @@ private function summaryMetrics(
|
||||
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>
|
||||
*/
|
||||
@ -938,6 +1030,77 @@ private function makeSummaryMetric(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{
|
||||
* concern_family: string,
|
||||
* affected_total: int,
|
||||
* reviewed_count: int,
|
||||
* follow_up_needed_count: int,
|
||||
* changed_since_review_count: int,
|
||||
* not_reviewed_count: int
|
||||
* }> $triageReviewSummaries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function triageReviewProgressFamilies(array $triageReviewSummaries): array
|
||||
{
|
||||
$families = [];
|
||||
|
||||
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
|
||||
$summary = $triageReviewSummaries[$family] ?? null;
|
||||
|
||||
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$families[] = [
|
||||
'concern_family' => $family,
|
||||
'label' => $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH ? 'Backup health' : 'Recovery evidence',
|
||||
'affected_total' => (int) ($summary['affected_total'] ?? 0),
|
||||
'reviewed_count' => (int) ($summary['reviewed_count'] ?? 0),
|
||||
'follow_up_needed_count' => (int) ($summary['follow_up_needed_count'] ?? 0),
|
||||
'changed_since_review_count' => (int) ($summary['changed_since_review_count'] ?? 0),
|
||||
'not_reviewed_count' => (int) ($summary['not_reviewed_count'] ?? 0),
|
||||
'reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_REVIEWED, 'Reviewed'),
|
||||
'follow_up_needed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, 'Follow-up needed'),
|
||||
'changed_since_review_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, 'Changed since review'),
|
||||
'not_reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_NOT_REVIEWED, 'Not reviewed'),
|
||||
];
|
||||
}
|
||||
|
||||
return $families;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function triageReviewBucketDestination(string $family, string $reviewState, string $label): array
|
||||
{
|
||||
$filters = match ($family) {
|
||||
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => [
|
||||
'backup_posture' => [
|
||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
||||
],
|
||||
'review_state' => [$reviewState],
|
||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
],
|
||||
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => [
|
||||
'recovery_evidence' => [
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
||||
],
|
||||
'review_state' => [$reviewState],
|
||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
],
|
||||
default => [
|
||||
'review_state' => [$reviewState],
|
||||
],
|
||||
};
|
||||
|
||||
return $this->filteredTenantRegistryTarget($filters, $label);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tenantContexts
|
||||
*/
|
||||
|
||||
@ -47,15 +47,14 @@ public function definition(): array
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'display_name' => fake()->company(),
|
||||
'is_default' => false,
|
||||
'is_enabled' => true,
|
||||
'connection_type' => ProviderConnectionType::Platform->value,
|
||||
'status' => 'needs_consent',
|
||||
'consent_status' => ProviderConsentStatus::Required->value,
|
||||
'consent_granted_at' => null,
|
||||
'consent_last_checked_at' => null,
|
||||
'consent_error_code' => null,
|
||||
'consent_error_message' => null,
|
||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
||||
'health_status' => 'unknown',
|
||||
'migration_review_required' => false,
|
||||
'migration_reviewed_at' => null,
|
||||
'scopes_granted' => [],
|
||||
@ -83,7 +82,7 @@ public function dedicated(): static
|
||||
public function consentGranted(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'connected',
|
||||
'is_enabled' => true,
|
||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||
'consent_granted_at' => now(),
|
||||
'consent_last_checked_at' => now(),
|
||||
@ -93,13 +92,19 @@ public function consentGranted(): static
|
||||
public function verifiedHealthy(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'connected',
|
||||
'is_enabled' => true,
|
||||
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||
'consent_granted_at' => now(),
|
||||
'consent_last_checked_at' => now(),
|
||||
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
||||
'health_status' => 'ok',
|
||||
'last_health_check_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function disabled(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'is_enabled' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
142
apps/platform/database/factories/TenantTriageReviewFactory.php
Normal file
142
apps/platform/database/factories/TenantTriageReviewFactory.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<TenantTriageReview>
|
||||
*/
|
||||
class TenantTriageReviewFactory extends Factory
|
||||
{
|
||||
protected $model = TenantTriageReview::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$snapshot = [
|
||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'concernState' => 'stale',
|
||||
'reasonCode' => 'latest_backup_stale',
|
||||
'severityKey' => 'latest_backup_stale',
|
||||
'supportingKey' => 'latest_backup_stale',
|
||||
];
|
||||
|
||||
return [
|
||||
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! is_numeric($tenant->workspace_id)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'current_state' => TenantTriageReview::STATE_REVIEWED,
|
||||
'reviewed_at' => now()->subMinutes(5),
|
||||
'reviewed_by_user_id' => User::factory(),
|
||||
'review_fingerprint' => $this->hashSnapshot($snapshot),
|
||||
'review_snapshot' => $snapshot,
|
||||
'last_seen_matching_at' => now()->subMinutes(5),
|
||||
'resolved_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function reviewed(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'current_state' => TenantTriageReview::STATE_REVIEWED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function followUpNeeded(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'current_state' => TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function backupHealth(): static
|
||||
{
|
||||
return $this->state(function (): array {
|
||||
$snapshot = [
|
||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'concernState' => 'stale',
|
||||
'reasonCode' => 'latest_backup_stale',
|
||||
'severityKey' => 'latest_backup_stale',
|
||||
'supportingKey' => 'latest_backup_stale',
|
||||
];
|
||||
|
||||
return [
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'review_fingerprint' => $this->hashSnapshot($snapshot),
|
||||
'review_snapshot' => $snapshot,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function recoveryEvidence(): static
|
||||
{
|
||||
return $this->state(function (): array {
|
||||
$snapshot = [
|
||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
'concernState' => 'weakened',
|
||||
'reasonCode' => 'failed',
|
||||
'severityKey' => 'failed',
|
||||
'supportingKey' => 'failed',
|
||||
];
|
||||
|
||||
return [
|
||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
||||
'review_fingerprint' => $this->hashSnapshot($snapshot),
|
||||
'review_snapshot' => $snapshot,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function resolved(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'resolved_at' => now()->subMinute(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function active(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'resolved_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function changedFingerprint(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'review_fingerprint' => hash('sha256', 'changed-fingerprint-'.fake()->uuid()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
*/
|
||||
private function hashSnapshot(array $snapshot): string
|
||||
{
|
||||
return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
});
|
||||
|
||||
DB::table('provider_connections')
|
||||
->where('status', 'disabled')
|
||||
->update(['is_enabled' => false]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->dropColumn('is_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->dropIndex(['tenant_id', 'provider', 'status']);
|
||||
$table->dropIndex(['tenant_id', 'provider', 'health_status']);
|
||||
$table->dropIndex(['workspace_id', 'provider', 'status']);
|
||||
$table->dropIndex(['workspace_id', 'provider', 'health_status']);
|
||||
$table->dropColumn(['status', 'health_status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('provider_connections', function (Blueprint $table): void {
|
||||
$table->string('status')->default('needs_consent');
|
||||
$table->string('health_status')->default('unknown');
|
||||
$table->index(['tenant_id', 'provider', 'status']);
|
||||
$table->index(['tenant_id', 'provider', 'health_status']);
|
||||
$table->index(['workspace_id', 'provider', 'status']);
|
||||
$table->index(['workspace_id', 'provider', 'health_status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_triage_reviews', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->string('concern_family', 64);
|
||||
$table->string('current_state', 64);
|
||||
$table->timestampTz('reviewed_at');
|
||||
$table->foreignId('reviewed_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('review_fingerprint', 64);
|
||||
$table->jsonb('review_snapshot')->default('{}');
|
||||
$table->timestampTz('last_seen_matching_at')->nullable();
|
||||
$table->timestampTz('resolved_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(
|
||||
['workspace_id', 'concern_family', 'resolved_at', 'tenant_id'],
|
||||
'tenant_triage_reviews_lookup_index',
|
||||
);
|
||||
$table->index(
|
||||
['tenant_id', 'concern_family', 'resolved_at'],
|
||||
'tenant_triage_reviews_tenant_family_index',
|
||||
);
|
||||
});
|
||||
|
||||
DB::statement("
|
||||
CREATE UNIQUE INDEX tenant_triage_reviews_active_unique
|
||||
ON tenant_triage_reviews (workspace_id, tenant_id, concern_family)
|
||||
WHERE resolved_at IS NULL
|
||||
");
|
||||
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement("
|
||||
ALTER TABLE tenant_triage_reviews
|
||||
ADD CONSTRAINT tenant_triage_reviews_current_state_check
|
||||
CHECK (current_state IN ('reviewed', 'follow_up_needed'))
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS tenant_triage_reviews_active_unique');
|
||||
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement('ALTER TABLE tenant_triage_reviews DROP CONSTRAINT IF EXISTS tenant_triage_reviews_current_state_check');
|
||||
}
|
||||
|
||||
Schema::dropIfExists('tenant_triage_reviews');
|
||||
}
|
||||
};
|
||||
@ -19,6 +19,49 @@
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (depth > 3) {
|
||||
return '[max-depth-reached]';
|
||||
@ -95,23 +138,36 @@
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const normalizedReason = normalizeReason(event.reason);
|
||||
const payload = {
|
||||
source: 'window.unhandledrejection',
|
||||
href: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: normalizeReason(event.reason),
|
||||
reason: normalizedReason,
|
||||
};
|
||||
|
||||
if (isExpectedBackgroundTransportFailure(normalizedReason)) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupeKey = toStableJson({
|
||||
source: payload.source,
|
||||
href: payload.href,
|
||||
reason: payload.reason,
|
||||
});
|
||||
|
||||
const payloadJson = toStableJson(payload);
|
||||
const nowMs = Date.now();
|
||||
|
||||
cleanupRecentKeys(nowMs);
|
||||
|
||||
if (recentKeys.has(payloadJson)) {
|
||||
if (recentKeys.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recentKeys.set(payloadJson, nowMs);
|
||||
recentKeys.set(dedupeKey, nowMs);
|
||||
|
||||
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
||||
});
|
||||
|
||||
@ -8,18 +8,17 @@
|
||||
|
||||
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
|
||||
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
|
||||
$lifecycle = is_string($state['lifecycle'] ?? null) ? (string) $state['lifecycle'] : null;
|
||||
$isEnabled = $state['is_enabled'] ?? null;
|
||||
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
|
||||
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
|
||||
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
|
||||
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
|
||||
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
||||
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
||||
|
||||
$isMissing = $connectionState === 'missing';
|
||||
$lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle);
|
||||
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
|
||||
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
|
||||
$legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status);
|
||||
$legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||
@ -52,6 +51,14 @@
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
||||
<dd>{{ $provider ?? 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$lifecycleSpec->color" :icon="$lifecycleSpec->icon" size="sm">
|
||||
{{ $lifecycleSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
|
||||
<dd>
|
||||
@ -76,25 +83,6 @@
|
||||
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
|
||||
<dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
|
||||
{{ $legacyStatusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
|
||||
<dd>
|
||||
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
|
||||
{{ $legacyHealthSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($lastErrorReason)
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
Last error reason: {{ $lastErrorReason }}
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : 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'] : []);
|
||||
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
|
||||
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
|
||||
@ -26,6 +28,28 @@
|
||||
};
|
||||
@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)
|
||||
<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">
|
||||
|
||||
@ -0,0 +1,563 @@
|
||||
<x-filament::page>
|
||||
@if (($hasActiveRuns ?? false) === true)
|
||||
<div wire:poll.5s="pollMatrix"></div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$reference = is_array($reference ?? null) ? $reference : [];
|
||||
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
|
||||
$rows = is_array($rows ?? null) ? $rows : [];
|
||||
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
|
||||
$stateOptions = is_array($stateOptions ?? null) ? $stateOptions : [];
|
||||
$severityOptions = is_array($severityOptions ?? null) ? $severityOptions : [];
|
||||
$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 : [];
|
||||
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
|
||||
$matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null;
|
||||
$activeFilterCount = $this->activeFilterCount();
|
||||
$activeFilterSummary = $this->activeFilterSummary();
|
||||
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - (int) ($reference['visibleTenantCount'] ?? 0));
|
||||
|
||||
$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);
|
||||
@endphp
|
||||
|
||||
<x-filament::section heading="Reference overview">
|
||||
<x-slot name="description">
|
||||
Compare assigned tenants is simulation only. It reuses the existing tenant-owned baseline compare path for the visible assigned set and does not create a workspace umbrella run.
|
||||
</x-slot>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
|
||||
@if ($referenceReady)
|
||||
<x-filament::badge color="success" icon="heroicon-m-check-badge" size="sm">
|
||||
Reference snapshot ready
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<x-filament::badge color="warning" icon="heroicon-m-exclamation-triangle" size="sm">
|
||||
Reference snapshot blocked
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@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: {{ (int) ($reference['visibleTenantCount'] ?? 0) }}.
|
||||
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
|
||||
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
|
||||
@endif
|
||||
</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
|
||||
|
||||
@if ($hiddenAssignedTenantCount > 0)
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing only the visible assigned set for your current access scope. Hidden tenants are excluded from summaries, rows, and drilldowns.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-xl 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">
|
||||
{{ (int) ($reference['visibleTenantCount'] ?? 0) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl 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">
|
||||
{{ count($rows) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl 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">Tenant sort</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl 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">Subject sort</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Filters">
|
||||
<x-slot name="description">
|
||||
Narrow the matrix by policy type, technical state, severity, or one focused subject. Only the visible tenant set contributes to the rendered counts, rows, and drilldowns.
|
||||
</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 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current 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 narrowing the matrix before you scan drift and follow-up links.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 lg: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>
|
||||
|
||||
@if ($hiddenAssignedTenantCount > 0)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Visible-set only
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($activeFilterSummary !== [])
|
||||
<div class="mt-2 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>
|
||||
|
||||
<form wire:submit.prevent="refreshMatrix">
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-2.5 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">
|
||||
@if ($activeFilterCount > 0)
|
||||
<x-filament::button tag="a" :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm">
|
||||
Clear all filters
|
||||
</x-filament::button>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
No filter reset needed
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<div class="relative" data-testid="baseline-compare-matrix-results">
|
||||
@if (($hasActiveRuns ?? false) === true)
|
||||
<div class="mb-2 flex justify-end" data-testid="baseline-compare-matrix-auto-refresh-note">
|
||||
<div class="flex items-center gap-2 rounded-full border border-gray-200 bg-white/95 px-3 py-1.5 text-xs shadow-sm dark:border-gray-800 dark:bg-gray-900/95">
|
||||
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
|
||||
Auto-refresh every 5 seconds while compare runs are queued or running.
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-6">
|
||||
<x-filament::section heading="Matrix signal legend">
|
||||
<div class="grid gap-3 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
|
||||
|
||||
@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-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
|
||||
|
||||
@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-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
|
||||
|
||||
@foreach ($trustLegend as $item)
|
||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||
{{ $item['label'] }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($emptyState !== null)
|
||||
<x-filament::section>
|
||||
<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-2" 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>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<x-filament::section heading="Tenant summaries">
|
||||
<x-slot name="description">
|
||||
Tenant-level freshness, trust, and breadth stay visible before you scan the subject-by-tenant body.
|
||||
</x-slot>
|
||||
|
||||
<div class="grid gap-4 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-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</h3>
|
||||
<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="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
@if (filled($tenantSummary['lastComparedAt'] ?? null))
|
||||
Compared {{ \Illuminate\Support\Carbon::parse($tenantSummary['lastComparedAt'])->diffForHumans() }}
|
||||
@else
|
||||
No completed compare yet
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Aligned</dt>
|
||||
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Drift</dt>
|
||||
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Missing</dt>
|
||||
<dd class="font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Ambiguous / not compared</dt>
|
||||
<dd class="font-semibold text-gray-950 dark:text-white">
|
||||
{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) + (int) ($tenantSummary['notComparedCount'] ?? 0) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center 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>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Subject-by-tenant matrix">
|
||||
<x-slot name="description">
|
||||
Row click is intentionally disabled. The subject column stays pinned while you scan across visible tenants.
|
||||
</x-slot>
|
||||
|
||||
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
|
||||
<div class="min-w-[72rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800">
|
||||
<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-[26rem] 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-72 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 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 ($rows as $row)
|
||||
@php
|
||||
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
||||
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
|
||||
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
|
||||
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? 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';
|
||||
@endphp
|
||||
|
||||
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" data-testid="baseline-compare-matrix-row" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}">
|
||||
<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="gray" size="sm">
|
||||
Ambiguous {{ (int) ($subject['ambiguousBreadth'] ?? 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
|
||||
</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);
|
||||
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
|
||||
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
|
||||
$cellState = (string) ($cell['state'] ?? '');
|
||||
$cellSeverity = is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null;
|
||||
$cellNeedsAttention = in_array($cellState, ['differ', 'missing', 'ambiguous'], true)
|
||||
|| in_array($cellSeverity, ['critical', 'high'], true);
|
||||
$cellNeedsRefresh = in_array($cellState, ['stale_result', 'not_compared'], true);
|
||||
$cellLooksHealthy = $cellState === 'match' && $cellNeedsAttention === false && $cellNeedsRefresh === false;
|
||||
$cellSurfaceClasses = $cellNeedsAttention
|
||||
? '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'
|
||||
: ($cellNeedsRefresh
|
||||
? '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'
|
||||
: ($cellLooksHealthy
|
||||
? 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40'));
|
||||
$cellPriorityLabel = $cellNeedsAttention
|
||||
? 'Needs attention'
|
||||
: ($cellNeedsRefresh ? 'Refresh recommended' : ($cellLooksHealthy ? 'Aligned' : 'Review'));
|
||||
$cellPriorityClasses = $cellNeedsAttention
|
||||
? 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300'
|
||||
: ($cellNeedsRefresh
|
||||
? 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300'
|
||||
: 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300');
|
||||
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
|
||||
$tenantId = (int) ($cell['tenantId'] ?? 0);
|
||||
$tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null;
|
||||
$cellFindingUrl = ($tenantId > 0 && filled($cell['findingId'] ?? null))
|
||||
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
|
||||
: null;
|
||||
$cellRunUrl = filled($cell['compareRunId'] ?? null)
|
||||
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
|
||||
<div class="space-y-3 rounded-xl border p-3 transition-colors group-hover:border-primary-200 group-hover:bg-white dark:group-hover:border-primary-900 dark:group-hover:bg-gray-950/60 {{ $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 {{ $cellPriorityClasses }}">
|
||||
{{ $cellPriorityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
||||
{{ $cellTrustSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
@if (filled($cell['reasonCode'] ?? null))
|
||||
<div>
|
||||
Reason: {{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) $cell['reasonCode'])) }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($cell['lastComparedAt'] ?? null))
|
||||
<div>
|
||||
Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (($cell['policyTypeCovered'] ?? true) === false)
|
||||
<div>Policy type coverage was not proven in the latest compare run.</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
@if ($cellFindingUrl)
|
||||
<x-filament::link :href="$cellFindingUrl" size="sm">
|
||||
Open finding
|
||||
</x-filament::link>
|
||||
@elseif ($tenantCompareUrl)
|
||||
<x-filament::link :href="$tenantCompareUrl" size="sm">
|
||||
Open tenant compare
|
||||
</x-filament::link>
|
||||
@endif
|
||||
|
||||
@if ($cellRunUrl)
|
||||
<x-filament::link :href="$cellRunUrl" color="gray" size="sm">
|
||||
Open run
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::page>
|
||||
@ -105,6 +105,7 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
||||
@livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [
|
||||
'items' => $overview['attention_items'] ?? [],
|
||||
'emptyState' => $overview['attention_empty_state'] ?? [],
|
||||
'triageReviewProgress' => $overview['triage_review_progress'] ?? [],
|
||||
], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none')))
|
||||
|
||||
@livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [
|
||||
|
||||
@ -51,22 +51,43 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span>
|
||||
|
||||
@if ($connection->display_name)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $connection->display_name }}</span>
|
||||
@endif
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->color"
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->color"
|
||||
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->icon"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->label }}
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->color"
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->color"
|
||||
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->icon"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->label }}
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->color"
|
||||
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->icon"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($connection->is_default)
|
||||
<x-filament::badge color="info">Default</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Last check: {{ $connection->last_health_check_at?->diffForHumans() ?? 'Never' }}</span>
|
||||
|
||||
@if ($connection->last_error_reason_code)
|
||||
<span>Last error: {{ $connection->last_error_reason_code }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
@php
|
||||
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
|
||||
/** @var array<string, mixed>|null $reviewState */
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@ -11,6 +12,8 @@
|
||||
'stale', 'degraded', 'weakened', 'unvalidated' => 'warning',
|
||||
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
|
||||
|
||||
<x-filament::section>
|
||||
@ -49,6 +52,24 @@ class="h-5 w-5 text-warning-500"
|
||||
{{ $context->arrivalSummary }}
|
||||
</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))
|
||||
<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 }}
|
||||
@ -89,9 +110,24 @@ class="h-5 w-5 text-warning-500"
|
||||
{{ $context->nextStep['helperText'] }}
|
||||
</div>
|
||||
@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>
|
||||
|
||||
@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>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,42 @@
|
||||
<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 === [])
|
||||
<div class="flex h-full flex-col justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Browser platform connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
@ -129,7 +129,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Polling verification connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -233,7 +233,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Polling bootstrap connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$verificationRun = OperationRun::factory()->create([
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Previously verified connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
@ -50,7 +50,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Current selected connection',
|
||||
'is_default' => false,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -142,7 +142,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Blocked review connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$verificationRun = OperationRun::factory()->create([
|
||||
@ -268,7 +268,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Browser assist connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -405,7 +405,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Browser next-step connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
|
||||
@ -118,7 +118,7 @@
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
TenantOnboardingSession::query()->create([
|
||||
@ -169,7 +169,7 @@
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -238,7 +238,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Spec172 completed connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$previousRun = OperationRun::factory()->create([
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\Finding;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||
|
||||
uses(BuildsBaselineCompareMatrixFixtures::class);
|
||||
|
||||
pest()->browser()->timeout(15_000);
|
||||
|
||||
it('smokes the baseline compare matrix render, filter interaction, 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'],
|
||||
);
|
||||
|
||||
$finding = $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('Visible-set baseline')
|
||||
->assertSee('Reference overview')
|
||||
->assertSee('No narrowing filters are active')
|
||||
->assertSee('Subject-by-tenant matrix')
|
||||
->assertSee('WiFi Corp Profile')
|
||||
->assertSee('Windows Compliance')
|
||||
->assertSee('Open finding');
|
||||
|
||||
$page->script(<<<'JS'
|
||||
const input = Array.from(document.querySelectorAll('input[type="checkbox"]')).find((element) => {
|
||||
if (element.getAttribute('aria-label') === 'Drift detected') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = element.closest('label');
|
||||
|
||||
return label instanceof HTMLLabelElement && label.innerText.includes('Drift detected');
|
||||
});
|
||||
|
||||
if (! (input instanceof HTMLInputElement)) {
|
||||
throw new Error('Drift detected checkbox not found.');
|
||||
}
|
||||
|
||||
input.click();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
JS);
|
||||
|
||||
$page
|
||||
->wait(1)
|
||||
->waitForText('Open finding')
|
||||
->assertDontSee('Windows Compliance')
|
||||
->click('Open finding')
|
||||
->waitForText('Back to compare matrix')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Back to compare matrix');
|
||||
});
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('stores successful admin consent on provider connection status', function () {
|
||||
it('stores successful admin consent on provider connection canonical state', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Contoso',
|
||||
@ -32,7 +32,9 @@
|
||||
->first();
|
||||
|
||||
expect($connection)->not->toBeNull()
|
||||
->and($connection?->status)->toBe('connected')
|
||||
->and($connection?->is_enabled)->toBeTrue()
|
||||
->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('granted')
|
||||
->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown')
|
||||
->and($connection?->last_error_reason_code)->toBeNull();
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
@ -81,11 +83,13 @@
|
||||
->first();
|
||||
|
||||
expect($connection)->not->toBeNull()
|
||||
->and($connection?->status)->toBe('needs_consent')
|
||||
->and($connection?->is_enabled)->toBeTrue()
|
||||
->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('required')
|
||||
->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown')
|
||||
->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConsentMissing);
|
||||
});
|
||||
|
||||
it('records consent callback errors on provider connection state', function () {
|
||||
it('records consent callback errors on provider connection canonical state', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'tenant_id' => 'tenant-2',
|
||||
'name' => 'Fabrikam',
|
||||
@ -105,7 +109,9 @@
|
||||
->first();
|
||||
|
||||
expect($connection)->not->toBeNull()
|
||||
->and($connection?->status)->toBe('error')
|
||||
->and($connection?->is_enabled)->toBeTrue()
|
||||
->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('failed')
|
||||
->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown')
|
||||
->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed)
|
||||
->and($connection?->last_error_message)->toBe('access_denied');
|
||||
|
||||
|
||||
@ -163,7 +163,7 @@
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'display_name' => 'Audit Connection',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
$draft = createOnboardingDraft([
|
||||
|
||||
@ -67,10 +67,9 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'revoked-audit-tenant-id',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
'consent_status' => 'granted',
|
||||
'verification_status' => 'healthy',
|
||||
'status' => 'connected',
|
||||
'health_status' => 'ok',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -105,7 +104,6 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
expect($connection->consent_status?->value ?? $connection->consent_status)->toBe('revoked')
|
||||
->and($connection->verification_status?->value ?? $connection->verification_status)->toBe('blocked')
|
||||
->and($connection->status)->toBe('needs_consent')
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and($run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentRevoked);
|
||||
|
||||
@ -115,6 +113,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$metadata = is_array($log?->metadata ?? null) ? $log->metadata : [];
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->status)->toBe('failed')
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
@ -125,4 +125,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
->and($log?->metadata['consent_status'] ?? null)->toBe('revoked')
|
||||
->and($log?->metadata['verification_status'] ?? null)->toBe('blocked')
|
||||
->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing);
|
||||
|
||||
expect($metadata)->not->toHaveKey('status')
|
||||
->and($metadata)->not->toHaveKey('health_status');
|
||||
});
|
||||
|
||||
@ -64,8 +64,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => 'verification-audit-tenant-id',
|
||||
'is_default' => true,
|
||||
'is_enabled' => true,
|
||||
'consent_status' => 'granted',
|
||||
'status' => 'connected',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
@ -101,16 +101,20 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$metadata = is_array($log?->metadata ?? null) ? $log->metadata : [];
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log?->status)->toBe('success')
|
||||
->and($log?->resource_type)->toBe('provider_connection')
|
||||
->and($log?->resource_id)->toBe((string) $connection->getKey())
|
||||
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
|
||||
->and($log?->metadata['connection_type'] ?? null)->toBe('platform')
|
||||
->and($log?->metadata['is_enabled'] ?? null)->toBeTrue()
|
||||
->and($log?->metadata['consent_status'] ?? null)->toBe('granted')
|
||||
->and($log?->metadata['verification_status'] ?? null)->toBe('healthy')
|
||||
->and($log?->metadata['effective_client_id'] ?? null)->toBe('platform-client-id')
|
||||
->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config')
|
||||
->and($log?->metadata['status'] ?? null)->toBe('connected')
|
||||
->and($log?->metadata['health_status'] ?? null)->toBe('ok');
|
||||
->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config');
|
||||
|
||||
expect($metadata)->not->toHaveKey('status')
|
||||
->and($metadata)->not->toHaveKey('health_status');
|
||||
});
|
||||
|
||||
@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
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 tenant and subject summaries 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['rows'])->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(count($wifiRow['cells']))->toBe(2);
|
||||
});
|
||||
|
||||
it('derives matrix cell precedence from compare freshness, evidence gaps, findings, and uncovered policy types', 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['rows'])->first(
|
||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
||||
);
|
||||
|
||||
$statesByTenant = collect($wifiRow['cells'] ?? [])
|
||||
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => (string) $cell['state']])
|
||||
->all();
|
||||
|
||||
expect($statesByTenant[(int) $matchTenant->getKey()] ?? null)->toBe('match')
|
||||
->and($statesByTenant[(int) $differTenant->getKey()] ?? null)->toBe('differ')
|
||||
->and($statesByTenant[(int) $missingTenant->getKey()] ?? null)->toBe('missing')
|
||||
->and($statesByTenant[(int) $ambiguousTenant->getKey()] ?? null)->toBe('ambiguous')
|
||||
->and($statesByTenant[(int) $notComparedTenant->getKey()] ?? null)->toBe('not_compared')
|
||||
->and($statesByTenant[(int) $staleTenant->getKey()] ?? null)->toBe('stale_result');
|
||||
});
|
||||
|
||||
it('applies policy-type, state, severity, and subject-focus filters honestly', 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['rows']))->toBe(1)
|
||||
->and($deviceOnly['rows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
||||
->and(count($driftOnly['rows']))->toBe(1)
|
||||
->and($driftOnly['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
||||
->and(count($subjectFocus['rows']))->toBe(1)
|
||||
->and($subjectFocus['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
||||
});
|
||||
@ -0,0 +1,95 @@
|
||||
<?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,8 +12,13 @@
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||
use App\Support\OperationRunOutcome;
|
||||
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 {
|
||||
bindFailHardGraphClient();
|
||||
@ -100,3 +105,28 @@
|
||||
expect($code)->toBeString();
|
||||
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);
|
||||
});
|
||||
|
||||
@ -0,0 +1,272 @@
|
||||
<?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,12 +8,17 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantTriageReview;
|
||||
use App\Models\User;
|
||||
use App\Services\PortfolioTriage\TenantTriageReviewService;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
trait BuildsPortfolioTriageFixtures
|
||||
@ -154,12 +159,58 @@ protected function portfolioTriageRegistryList(User $user, Tenant $workspaceTena
|
||||
protected function portfolioReturnFilters(
|
||||
array $backupPosture = [],
|
||||
array $recoveryEvidence = [],
|
||||
array $reviewState = [],
|
||||
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
): array {
|
||||
return [
|
||||
'backup_posture' => $backupPosture,
|
||||
'recovery_evidence' => $recoveryEvidence,
|
||||
'review_state' => $reviewState,
|
||||
'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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +195,56 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($reportCount)->toBe(0);
|
||||
});
|
||||
|
||||
it('skips tenant when the default microsoft provider connection is disabled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$connection = ensureDefaultProviderConnection($tenant);
|
||||
$connection->forceFill([
|
||||
'is_enabled' => false,
|
||||
])->save();
|
||||
|
||||
$job = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
buildScanReportService(scanJobGraphMock()),
|
||||
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('skips tenant when the default microsoft provider connection lacks granted consent', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$connection = ensureDefaultProviderConnection($tenant);
|
||||
$connection->forceFill([
|
||||
'consent_status' => \App\Support\Providers\ProviderConsentStatus::Required->value,
|
||||
])->save();
|
||||
|
||||
$job = new ScanEntraAdminRolesJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
buildScanReportService(scanJobGraphMock()),
|
||||
new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog),
|
||||
app(\App\Services\OperationRunService::class),
|
||||
);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'entra.admin_roles.scan')
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('Graph failure marks OperationRun as failed and re-throws', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||
|
||||
it('renders the baseline compare matrix with reference truth, legends, and explicit drilldowns', 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('Reference overview')
|
||||
->assertSee('fi-fo-checkbox-list', false)
|
||||
->assertSee('fi-fo-select', false)
|
||||
->assertSee('State legend')
|
||||
->assertSee('Tenant summaries')
|
||||
->assertSee('Subject-by-tenant matrix')
|
||||
->assertSee('No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.')
|
||||
->assertSee('1 hidden by access scope')
|
||||
->assertSee('WiFi Corp Profile')
|
||||
->assertSee((string) $fixture['visibleTenant']->name)
|
||||
->assertSee((string) $fixture['visibleTenantTwo']->name)
|
||||
->assertSee('Needs attention')
|
||||
->assertSee('Open finding')
|
||||
->assertSee('Open tenant compare')
|
||||
->assertSee('data-testid="matrix-active-filters"', false)
|
||||
->assertSee('sticky left-0', false);
|
||||
});
|
||||
|
||||
it('keeps query-backed filters and subject focus on the matrix route and drilldown links', 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',
|
||||
['severity' => 'critical'],
|
||||
);
|
||||
|
||||
$this->makeBaselineCompareMatrixRun(
|
||||
$fixture['visibleTenantTwo'],
|
||||
$fixture['profile'],
|
||||
$fixture['snapshot'],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
'state' => ['differ'],
|
||||
'severity' => ['critical'],
|
||||
'subject_key' => 'wifi-corp-profile',
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertSee('4 active filters')
|
||||
->assertSee('Policy types: 1')
|
||||
->assertSee('Focused subject: wifi-corp-profile')
|
||||
->assertSee('Clear subject focus')
|
||||
->assertDontSee('Windows Compliance');
|
||||
|
||||
$tenantCompareUrl = $component->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
||||
$findingUrl = $component->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
||||
|
||||
expect($tenantCompareUrl)->toContain('baseline_profile_id='.(int) $fixture['profile']->getKey())
|
||||
->and($tenantCompareUrl)->toContain('subject_key=wifi-corp-profile')
|
||||
->and($tenantCompareUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix')
|
||||
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
||||
});
|
||||
|
||||
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 = \App\Models\User::factory()->create();
|
||||
|
||||
\App\Models\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 note instead of a perpetual loading 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('Auto-refresh every 5 seconds while compare runs are queued or running.')
|
||||
->assertSee('wire:poll.5s="pollMatrix"', false)
|
||||
->assertDontSee('Refreshing matrix');
|
||||
});
|
||||
|
||||
it('renders an empty state when no rows match the current filters', 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']).'?state[]=missing')
|
||||
->assertOk()
|
||||
->assertSee('No rows match the current filters');
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
@ -8,6 +9,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -132,3 +134,64 @@
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows open-compare-matrix and compare-assigned-tenants header actions with simulation-only copy', 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);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionExists('openCompareMatrix', fn (Action $action): bool => $action->getLabel() === 'Open compare matrix'
|
||||
&& $action->getUrl() === BaselineProfileResource::compareMatrixUrl($profile))
|
||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
||||
&& $action->isConfirmationRequired()
|
||||
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
||||
});
|
||||
|
||||
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members', 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('openCompareMatrix')
|
||||
->assertActionVisible('compareAssignedTenants')
|
||||
->assertActionDisabled('compareAssignedTenants');
|
||||
});
|
||||
|
||||
@ -61,11 +61,11 @@
|
||||
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($visibleColumnNames)->toContain('consent_status', 'verification_status');
|
||||
expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status');
|
||||
expect($visibleColumnNames)->not->toContain('status');
|
||||
expect($visibleColumnNames)->not->toContain('health_status');
|
||||
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('status'))->toBeNull();
|
||||
expect($table->getColumn('health_status'))->toBeNull();
|
||||
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
expect($table->getColumn('migration_review_required'))->not->toBeNull();
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'status' => 'needs_consent',
|
||||
'consent_status' => 'required',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
@ -66,7 +66,7 @@
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'status' => 'needs_consent',
|
||||
'consent_status' => 'required',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => $tenant->tenant_id,
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
|
||||
@ -27,7 +27,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
|
||||
'tenant_id' => $tenant->id,
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
'status' => 'connected',
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
|
||||
@ -6,13 +6,20 @@
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
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\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
@ -26,6 +33,18 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
|
||||
], 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 {
|
||||
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
|
||||
$this->actingAs($user);
|
||||
@ -164,3 +183,71 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
|
||||
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
|
||||
], 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);
|
||||
});
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
<?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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user